diff --git a/Cargo.lock b/Cargo.lock index 1eb63b6..772ae93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,12 +23,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -129,21 +135,6 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cc" version = "1.2.53" @@ -220,20 +211,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -265,65 +242,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "deranged" version = "0.5.5" @@ -365,12 +283,6 @@ dependencies = [ "syn", ] -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -396,6 +308,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -414,12 +338,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foreign-types" version = "0.3.2" @@ -528,13 +446,11 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "ahash", ] [[package]] @@ -543,6 +459,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -773,12 +698,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -810,28 +729,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -854,15 +751,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -902,10 +790,15 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "libsqlite3-sys" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] [[package]] name = "linux-raw-sys" @@ -934,15 +827,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "matchers" version = "0.2.0" @@ -971,7 +855,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] @@ -1102,12 +985,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1171,27 +1048,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1295,6 +1151,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustagent" version = "0.1.0" @@ -1303,37 +1173,23 @@ dependencies = [ "async-trait", "chrono", "clap", - "crossterm", "dirs", - "ratatui", "regex", "reqwest", + "rusqlite", "serde", "serde_json", "shellexpand", "tempfile", "tokio", + "tokio-rusqlite", "toml", "tracing", "tracing-appender", "tracing-subscriber", - "tui-textarea", "uuid", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.3" @@ -1343,7 +1199,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -1518,27 +1374,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1577,40 +1412,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - [[package]] name = "subtle" version = "2.6.1" @@ -1678,7 +1485,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -1810,6 +1617,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rusqlite" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65501378eb676f400c57991f42cbd0986827ab5c5200c53f206d710fb32a945" +dependencies = [ + "crossbeam-channel", + "rusqlite", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1998,52 +1816,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tui-textarea" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" -dependencies = [ - "crossterm", - "ratatui", - "unicode-width 0.2.0", -] - [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - [[package]] name = "untrusted" version = "0.9.0" @@ -2097,6 +1875,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -2190,28 +1974,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -2548,6 +2310,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/docs/implementation-plans/2026-02-07-v2-phase1/phase_01.md b/docs/implementation-plans/2026-02-07-v2-phase1/phase_01.md new file mode 100644 index 0000000..c43497e --- /dev/null +++ b/docs/implementation-plans/2026-02-07-v2-phase1/phase_01.md @@ -0,0 +1,375 @@ +# Rustagent V2 Phase 1a: Database + Projects + +**Goal:** Set up the central SQLite database with WAL mode, schema migrations, and project registration CRUD with CLI. + +**Architecture:** Single SQLite database at XDG data dir (`~/.local/share/rustagent/rustagent.db`). All access through one `tokio_rusqlite::Connection` (internally Arc-wrapped, Clone-cheap). WAL mode + `BEGIN IMMEDIATE` for all write transactions. Hand-rolled sequential migrations tracked via `schema_version` table. + +**Tech Stack:** Rust (edition 2024), rusqlite 0.32 (bundled), tokio-rusqlite 0.6, clap 4.5 (derive), serde/serde_json, chrono, uuid, dirs, anyhow, tokio + +**Scope:** Phase 1 of 4 from the v2 architecture (Phase 1a: Database + Projects) + +**Codebase verified:** 2026-02-07 + +**Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### P1a.AC1: Database initialization +- **P1a.AC1.1 Success:** Database file created at `~/.local/share/rustagent/rustagent.db` on first startup +- **P1a.AC1.2 Success:** WAL mode enabled, `foreign_keys = ON`, `busy_timeout = 5000` +- **P1a.AC1.3 Success:** All tables created: `schema_version`, `projects`, `nodes`, `edges`, `sessions`, `nodes_fts` (virtual), `worker_conversations`, plus FTS sync triggers and indexes +- **P1a.AC1.4 Success:** `schema_version` table contains version 1 after fresh init + +### P1a.AC2: Schema versioning and migrations +- **P1a.AC2.1 Success:** Fresh database: full schema created, version set to 1 +- **P1a.AC2.2 Success:** Database at current version: no migration runs, proceeds normally +- **P1a.AC2.3 Failure:** Database newer than binary: returns clear error "your database was created by a newer version of rustagent, please upgrade" + +### P1a.AC3: Project registration +- **P1a.AC3.1 Success:** `rustagent project add ` registers a project with auto-generated ID (`ra-` + 4 hex chars) +- **P1a.AC3.2 Success:** `rustagent project list` shows all registered projects +- **P1a.AC3.3 Success:** `rustagent project show ` returns project details +- **P1a.AC3.4 Success:** `rustagent project remove ` deletes a project record +- **P1a.AC3.5 Failure:** Adding a project with a duplicate name returns error +- **P1a.AC3.6 Success:** When no `--project` flag, CLI resolves project from current working directory by matching registered project paths + +--- + + + + +### Task 1: Add new dependencies to Cargo.toml + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/Cargo.toml` + +**Implementation:** + +Add the following to `[dependencies]` (after the existing entries): + +```toml +rusqlite = { version = "0.32", features = ["bundled"] } +tokio-rusqlite = "0.6" +``` + +Remove the TUI dependencies that are no longer needed (v2 replaces TUI with web UI): + +```toml +# REMOVE these three lines: +ratatui = "0.29" +crossterm = "0.28" +tui-textarea = { version = "0.7", default-features = false, features = ["crossterm"] } +``` + +**Verification:** + +Run: `cargo check` +Expected: Compiles without errors (TUI removal will cause compile errors in src/tui/ and main.rs — that's expected and addressed in Task 2) + +**Commit:** `chore: add rusqlite and tokio-rusqlite, remove TUI deps` + + + + +### Task 2: Remove TUI module and update main.rs/lib.rs + +**Files:** +- Delete: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tui/` (entire directory) +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — remove `pub mod tui;` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — remove `Tui` variant from `Commands` enum, remove the `Commands::Tui` match arm, remove the `unwrap_or(Commands::Tui)` default (replace with showing help when no subcommand) + +**Implementation:** + +In `lib.rs`, remove the `pub mod tui;` line. Keep all other module declarations. + +In `main.rs`: +- Remove the `Tui` variant from `Commands` enum +- Change `cli.command.unwrap_or(Commands::Tui)` to handle `None` by printing help and exiting +- Remove the entire `Commands::Tui` match arm and its `use rustagent::tui` import + +The v1 `Init`, `Plan`, and `Run` commands stay for now — they'll be replaced incrementally as v2 modules come online. + +**Verification:** + +Run: `cargo check` +Expected: Compiles cleanly. No references to ratatui, crossterm, or tui remain. + +Run: `cargo test` +Expected: All existing tests still pass (TUI had no tests). + +**Commit:** `refactor: remove TUI module (replaced by web UI in v2)` + + + + +### Task 3: Create database module with initialization and migrations + +**Verifies:** P1a.AC1.1, P1a.AC1.2, P1a.AC1.3, P1a.AC1.4, P1a.AC2.1, P1a.AC2.2, P1a.AC2.3 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/db/mod.rs` +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/db/migrations.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod db;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/db_test.rs` (integration) + +**Implementation:** + +`src/db/mod.rs`: +- `Database` struct wrapping `tokio_rusqlite::Connection` +- `Database::open(path: &Path) -> Result` — opens connection, calls `init_pragmas` then `run_migrations` +- `Database::open_in_memory() -> Result` — for testing +- `Database::connection(&self) -> &tokio_rusqlite::Connection` — accessor +- Private `init_pragmas(conn: &rusqlite::Connection)` — sets WAL, foreign_keys, busy_timeout, wal_autocheckpoint +- The `Database` should implement `Clone` (delegates to inner `Connection::clone()`) + +`src/db/migrations.rs`: +- `const CURRENT_VERSION: u32 = 1;` +- `pub fn run_migrations(conn: &rusqlite::Connection) -> Result<()>`: + 1. Check if `schema_version` table exists (query `sqlite_master`) + 2. If not: fresh DB — run `create_schema_v1(conn)`, insert version 1 + 3. If exists: read version. If == CURRENT_VERSION, return Ok. If > CURRENT_VERSION, return error. If < CURRENT_VERSION, run sequential migrations. +- `fn create_schema_v1(conn: &rusqlite::Connection) -> Result<()>` — all CREATE TABLE/INDEX/TRIGGER/VIRTUAL TABLE statements from the architecture doc's Database Schema section + +The full schema includes: `schema_version`, `projects`, `nodes` (with 3 indexes), `edges` (with 3 indexes), `sessions`, `nodes_fts` (FTS5 virtual table with content sync), FTS sync triggers (nodes_ai, nodes_ad, nodes_au), `worker_conversations`. + +**Testing:** + +Tests must verify each AC listed above: +- P1a.AC1.1: `Database::open` creates file at specified path +- P1a.AC1.2: After open, query `PRAGMA journal_mode` returns "wal", `PRAGMA foreign_keys` returns 1 +- P1a.AC1.3: After open, all tables exist in `sqlite_master` (projects, nodes, edges, sessions, nodes_fts, worker_conversations, schema_version) +- P1a.AC1.4: After fresh init, `schema_version` contains version 1 +- P1a.AC2.1: `open_in_memory()` creates full schema and sets version 1 +- P1a.AC2.2: Opening an already-initialized DB does not error and version remains 1 +- P1a.AC2.3: Manually set version to 999, reopen — returns error containing "newer version" + +Use `Database::open_in_memory()` for most tests. Use `tempfile::TempDir` for the file creation test. + +Follow project testing patterns: integration tests in `tests/db_test.rs`, `#[tokio::test]` for async, `assert_eq!`/`assert!` for assertions. + +**Verification:** +Run: `cargo test db_test` +Expected: All tests pass + +**Commit:** `feat(db): database initialization with WAL mode, schema v1, and migration framework` + + + + + + + +### Task 4: Create Project type and store + +**Verifies:** P1a.AC3.1, P1a.AC3.5 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/project.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod project;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/project_test.rs` (integration) + +**Implementation:** + +`src/project.rs`: + +```rust +pub struct Project { + pub id: String, + pub name: String, + pub path: PathBuf, + pub registered_at: DateTime, + pub config_overrides: Option, // JSON string, stored as TEXT in DB + pub metadata: String, // JSON string, stored as TEXT in DB, default "{}" +} +``` + +**Architecture deviation:** The architecture shows `config_overrides: Option` and `metadata: HashMap`. This implementation stores both as JSON strings in the database (matching the SQLite TEXT columns). The typed structs can be deserialized on demand when accessed by application code. This avoids a deserialization step on every DB read and matches the DB schema directly. + +`ProjectStore` struct wrapping `Database`: +- `new(db: Database) -> Self` +- `async fn add(&self, name: &str, path: &Path) -> Result` — generates ID (`ra-` + 4 hex chars from uuid v4), inserts with `BEGIN IMMEDIATE`, returns the created `Project`. Fails if name already exists (UNIQUE constraint). +- `async fn list(&self) -> Result>` — returns all projects ordered by name +- `async fn get_by_name(&self, name: &str) -> Result>` — lookup by name +- `async fn get_by_path(&self, path: &Path) -> Result>` — lookup by path (for cwd resolution). Canonicalize both stored path and query path before comparison. +- `async fn remove(&self, name: &str) -> Result` — delete by name, returns true if deleted + +ID generation: `format!("ra-{}", &uuid::Uuid::new_v4().to_string().replace("-", "")[..4])` + +All write operations use `BEGIN IMMEDIATE` via `conn.call(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; ... })`. + +**Testing:** + +Tests must verify: +- P1a.AC3.1: `add("my-api", "/tmp/test")` creates project with `ra-` prefixed 4-char hex ID, correct name and path +- P1a.AC3.5: Adding two projects with the same name returns an error + +Use `Database::open_in_memory()`. Follow project patterns: `#[tokio::test]`, `tests/project_test.rs`. + +**Verification:** +Run: `cargo test project_test` +Expected: All tests pass + +**Commit:** `feat(project): Project type and ProjectStore with CRUD operations` + + + + +### Task 5: Project store — list, show, remove, resolve from cwd + +**Verifies:** P1a.AC3.2, P1a.AC3.3, P1a.AC3.4, P1a.AC3.6 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/project.rs` (if not already covered in Task 4) +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/project_test.rs` (add more tests) + +**Implementation:** + +This task adds tests for the remaining ProjectStore methods implemented in Task 4. If any methods were left as stubs, implement them now. + +**Testing:** + +Tests must verify: +- P1a.AC3.2: After adding 3 projects, `list()` returns all 3 ordered by name +- P1a.AC3.3: After adding a project, `get_by_name("my-api")` returns the project with correct details +- P1a.AC3.4: After adding then removing a project, `get_by_name` returns None +- P1a.AC3.6: After adding a project with path `/tmp/test-proj`, `get_by_path("/tmp/test-proj")` returns it + +**Verification:** +Run: `cargo test project_test` +Expected: All tests pass + +**Commit:** `test(project): complete project store test coverage` + + + + + + + +### Task 6: Wire up `project` CLI subcommand + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add `Project` subcommand with nested `add`/`list`/`show`/`remove` + +**Implementation:** + +Add a `Project` variant to `Commands` with nested subcommands: + +```rust +/// Manage projects +Project { + #[command(subcommand)] + action: ProjectAction, +}, +``` + +```rust +#[derive(Subcommand)] +enum ProjectAction { + /// Register a project + Add { + /// Friendly name for the project + name: String, + /// Path to the project directory + path: String, + }, + /// List all registered projects + List, + /// Show project details + Show { + /// Project name + name: String, + }, + /// Remove a registered project + Remove { + /// Project name + name: String, + }, +} +``` + +In the `main()` match: +- `ProjectAction::Add` — open database (see helper below), create `ProjectStore`, call `add()`, print the created project +- `ProjectAction::List` — open database, list, print formatted table +- `ProjectAction::Show` — open database, get_by_name, print details (or "not found") +- `ProjectAction::Remove` — open database, remove, print confirmation + +Add a helper function to get the database path and open it: + +```rust +fn db_path() -> anyhow::Result { + let data_dir = dirs::data_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine XDG data directory"))?; + let db_dir = data_dir.join("rustagent"); + std::fs::create_dir_all(&db_dir)?; + Ok(db_dir.join("rustagent.db")) +} +``` + +**Verification:** + +Run: `cargo build` +Expected: Compiles cleanly + +Run: `cargo run -- project add test-proj .` +Expected: Prints something like "Registered project 'test-proj' (ra-a3f8) at /Users/david.hagerty/code/personal/rustagent/new-directions" + +Run: `cargo run -- project list` +Expected: Shows the registered project + +Run: `cargo run -- project show test-proj` +Expected: Shows project details + +Run: `cargo run -- project remove test-proj` +Expected: Prints confirmation + +**Commit:** `feat(cli): add project subcommand (add/list/show/remove)` + + + + +### Task 7: Add `--project` flag and cwd resolution to CLI + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add global `--project` flag to `Cli` struct + +**Implementation:** + +Add a `--project` flag to the top-level `Cli` struct: + +```rust +#[derive(Parser)] +#[command(name = "rustagent")] +struct Cli { + /// Project name (if omitted, resolves from current directory) + #[arg(long, global = true)] + project: Option, + + #[command(subcommand)] + command: Option, +} +``` + +Add a helper function `resolve_project` that: +1. If `--project ` was given, looks up by name +2. If not, gets the current working directory and looks up by path +3. Returns `Option` (None is valid — some commands don't need a project) + +This resolver is not used by the `project` subcommand itself (which takes explicit name args), but will be used by later commands like `run`, `tasks`, `status`. For now, just define the function — it gets exercised when those commands are wired up in Phase 1b. + +**Verification:** + +Run: `cargo build` +Expected: Compiles cleanly + +Run: `cargo run -- --help` +Expected: Shows `--project` in global options + +**Commit:** `feat(cli): add --project global flag with cwd resolution helper` + + + diff --git a/docs/implementation-plans/2026-02-07-v2-phase1/phase_02.md b/docs/implementation-plans/2026-02-07-v2-phase1/phase_02.md new file mode 100644 index 0000000..e47b816 --- /dev/null +++ b/docs/implementation-plans/2026-02-07-v2-phase1/phase_02.md @@ -0,0 +1,535 @@ +# Rustagent V2 Phase 1b: Graph Model + Node Lifecycle + +**Goal:** Implement the unified work graph — types, SQLite store, query builders, dependency resolution, FTS5 search, graph tools for agents, and CLI commands. + +**Architecture:** All entities (goals, tasks, decisions, options, outcomes, observations, revisits) are nodes in one DAG. Relationships are edges. Task surfacing (`ready`, `next`) is derived from dependency resolution over `DependsOn` edges. FTS5 provides full-text search. Atomic task claiming via conditional UPDATE. All writes through single `tokio_rusqlite::Connection` with `BEGIN IMMEDIATE`. + +**Tech Stack:** Rust (edition 2024), rusqlite 0.32 (bundled), tokio-rusqlite 0.6, async-trait, serde/serde_json, chrono, uuid, anyhow + +**Scope:** Phase 2 of 4 from the v2 architecture (Phase 1b: Graph Model + Node Lifecycle) + +**Codebase verified:** 2026-02-07 + +**Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` + +**Depends on:** Phase 1a (database module, project store) + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### P1b.AC1: Graph node types and data model +- **P1b.AC1.1 Success:** `GraphNode` struct with all fields from architecture (id, project_id, node_type, title, description, status, priority, assigned_to, created_by, labels, timestamps, blocked_reason, metadata) +- **P1b.AC1.2 Success:** 7 `NodeType` variants: Goal, Task, Decision, Option, Outcome, Observation, Revisit +- **P1b.AC1.3 Success:** `NodeStatus` enum with all variants, valid status transitions per node type enforced +- **P1b.AC1.4 Success:** `GraphEdge` struct with id, edge_type, from_node, to_node, label, created_at +- **P1b.AC1.5 Success:** 7 `EdgeType` variants: Contains, DependsOn, LeadsTo, Chosen, Rejected, Supersedes, Informs + +### P1b.AC2: Hierarchical ID generation +- **P1b.AC2.1 Success:** Goal IDs: `ra-` + 4 hex chars from UUID v4 +- **P1b.AC2.2 Success:** Child IDs: parent_id + `.N` where N is sequential counter from parent's `next_child_seq` metadata +- **P1b.AC2.3 Success:** Full dotted path is the primary key (e.g., `ra-a3f8.1.3`) +- **P1b.AC2.4 Success:** Edge IDs: `e-` + 8 hex chars from UUID v4 + +### P1b.AC3: GraphStore CRUD +- **P1b.AC3.1 Success:** `create_node` inserts node with all fields, returns Ok +- **P1b.AC3.2 Success:** `get_node(id)` returns the node or None +- **P1b.AC3.3 Success:** `update_node` modifies status and/or metadata +- **P1b.AC3.4 Success:** `add_edge` inserts edge; `get_edges` returns edges for a node +- **P1b.AC3.5 Success:** `get_children(node_id)` returns child nodes via Contains edges +- **P1b.AC3.6 Success:** `get_subtree(node_id)` returns all descendant nodes recursively + +### P1b.AC4: Dependency resolution and task surfacing +- **P1b.AC4.1 Success:** Task moves from Pending to Ready when all DependsOn targets are Completed +- **P1b.AC4.2 Success:** `get_ready_tasks(goal_id)` returns only tasks in Ready status with all deps satisfied +- **P1b.AC4.3 Success:** `get_next_task(goal_id)` returns highest-priority Ready task, breaking ties by downstream unblock count + +### P1b.AC5: Atomic task claiming +- **P1b.AC5.1 Success:** `claim_task(node_id, agent_id)` sets status to Claimed and assigned_to atomically +- **P1b.AC5.2 Success:** If task is not Ready, claim returns false (another worker got there first) + +### P1b.AC6: Full-text search +- **P1b.AC6.1 Success:** `search_nodes(query)` returns nodes matching title or description via FTS5 +- **P1b.AC6.2 Success:** Search can filter by project_id and node_type + +### P1b.AC7: Graph tools for agents +- **P1b.AC7.1 Success:** All low-level tools work: `create_node`, `update_node`, `add_edge`, `query_nodes`, `search_nodes` +- **P1b.AC7.2 Success:** All high-level tools work: `claim_task`, `log_decision`, `choose_option`, `record_outcome`, `record_observation`, `revisit` + +--- + + + + +### Task 1: Graph types — NodeType, EdgeType, NodeStatus, Priority, GraphNode, GraphEdge + +**Verifies:** P1b.AC1.1, P1b.AC1.2, P1b.AC1.3, P1b.AC1.4, P1b.AC1.5 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod graph;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_types_test.rs` + +**Implementation:** + +`src/graph/mod.rs` — define these types (all `#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]`): + +- `NodeType` enum: Goal, Task, Decision, Option, Outcome, Observation, Revisit. Implement `Display` and `FromStr` for serialization to/from the lowercase string used in the DB (`"goal"`, `"task"`, etc.). + +- `EdgeType` enum: Contains, DependsOn, LeadsTo, Chosen, Rejected, Supersedes, Informs. Same Display/FromStr pattern. + +- `NodeStatus` enum: Pending, Active, Completed, Cancelled, Ready, Claimed, InProgress, Review, Blocked, Failed, Decided, Superseded, Abandoned, Chosen, Rejected. Same Display/FromStr. + +- `Priority` enum: Critical, High, Medium, Low. Same Display/FromStr. + +- `GraphNode` struct per architecture (line 453-469 of design doc). Fields: + - `id: String`, `project_id: String`, `node_type: NodeType`, `title: String`, `description: String`, `status: NodeStatus`, `priority: Option`, `assigned_to: Option`, `created_by: Option`, `labels: Vec`, `created_at: DateTime`, `started_at: Option>`, `completed_at: Option>`, `blocked_reason: Option`, `metadata: HashMap` + +- `GraphEdge` struct per architecture (line 393-401). Fields: + - `id: String`, `edge_type: EdgeType`, `from_node: String`, `to_node: String`, `label: Option`, `created_at: DateTime` + +- `fn valid_statuses(node_type: &NodeType) -> Vec` — returns the valid statuses for each node type per the architecture (lines 442-448). + +- `fn validate_status(node_type: &NodeType, status: &NodeStatus) -> Result<()>` — returns error if status is not valid for the node type. + +Declare sub-modules: `pub mod store;`, `pub mod dependency;` + +**Note:** The architecture lists a separate `src/graph/query.rs` for query builders. In this implementation, `NodeQuery`, `EdgeDirection`, and `WorkGraph` are defined directly in `store.rs` alongside the `GraphStore` trait, avoiding premature file separation. The query builder functionality is part of the trait contract. + +**Testing:** + +Tests must verify: +- P1b.AC1.2: All 7 NodeType variants roundtrip through Display/FromStr +- P1b.AC1.3: `validate_status(Task, Ready)` is Ok; `validate_status(Goal, Ready)` is Err +- P1b.AC1.5: All 7 EdgeType variants roundtrip through Display/FromStr +- Serialization: GraphNode and GraphEdge serialize to/from JSON correctly + +**Verification:** +Run: `cargo test graph_types_test` +Expected: All tests pass + +**Commit:** `feat(graph): core types — NodeType, EdgeType, NodeStatus, GraphNode, GraphEdge` + + + + +### Task 2: ID generation helpers + +**Verifies:** P1b.AC2.1, P1b.AC2.2, P1b.AC2.3, P1b.AC2.4 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add ID generation functions +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_types_test.rs` — add ID tests + +**Implementation:** + +Add to `src/graph/mod.rs`: + +- `pub fn generate_goal_id() -> String` — `format!("ra-{}", &uuid::Uuid::new_v4().simple().to_string()[..4])` +- `pub fn generate_child_id(parent_id: &str, seq: u32) -> String` — `format!("{}.{}", parent_id, seq)` +- `pub fn generate_edge_id() -> String` — `format!("e-{}", &uuid::Uuid::new_v4().simple().to_string()[..8])` +- `pub fn parent_id(id: &str) -> Option<&str>` — extracts parent from hierarchical ID (e.g., `"ra-a3f8.1.3"` -> `Some("ra-a3f8.1")`) + +**Testing:** + +Tests must verify: +- P1b.AC2.1: `generate_goal_id()` starts with `"ra-"` and has 4 hex chars after prefix +- P1b.AC2.2: `generate_child_id("ra-a3f8", 1)` returns `"ra-a3f8.1"`; `generate_child_id("ra-a3f8.1", 3)` returns `"ra-a3f8.1.3"` +- P1b.AC2.3: Generated IDs are valid as primary keys (no special chars beyond `-` and `.`) +- P1b.AC2.4: `generate_edge_id()` starts with `"e-"` and has 8 hex chars +- `parent_id("ra-a3f8.1.3")` returns `Some("ra-a3f8.1")`; `parent_id("ra-a3f8")` returns `None` + +**Verification:** +Run: `cargo test graph_types_test` +Expected: All tests pass + +**Commit:** `feat(graph): hierarchical ID generation (goal, child, edge)` + + + + + + + +### Task 3: GraphStore trait definition + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` + +**Implementation:** + +Define the `GraphStore` trait from the architecture (lines 1742-1778 of design doc). This is a trait definition only — no implementation yet. + +**Architecture deviation:** The architecture's `update_node` takes `&GraphNode` (full struct replacement). This implementation uses a partial-update signature with optional fields instead, which avoids read-modify-write races — callers update only the fields they intend to change without needing to read the full node first. + +```rust +#[async_trait] +pub trait GraphStore: Send + Sync { + // Node CRUD + async fn create_node(&self, node: &GraphNode) -> Result<()>; + async fn update_node(&self, id: &str, status: Option, title: Option<&str>, description: Option<&str>, metadata: Option<&HashMap>) -> Result<()>; + async fn get_node(&self, id: &str) -> Result>; + async fn query_nodes(&self, query: &NodeQuery) -> Result>; + + // Task-specific + async fn claim_task(&self, node_id: &str, agent_id: &str) -> Result; + async fn get_ready_tasks(&self, goal_id: &str) -> Result>; + async fn get_next_task(&self, goal_id: &str) -> Result>; + + // Edge operations + async fn add_edge(&self, edge: &GraphEdge) -> Result<()>; + async fn remove_edge(&self, edge_id: &str) -> Result<()>; + async fn get_edges(&self, node_id: &str, direction: EdgeDirection) -> Result>; + + // Graph queries + async fn get_children(&self, node_id: &str) -> Result>; + async fn get_subtree(&self, node_id: &str) -> Result>; + async fn get_active_decisions(&self, project_id: &str) -> Result>; // Now mode + async fn get_full_graph(&self, goal_id: &str) -> Result; // History mode + async fn search_nodes(&self, query: &str, project_id: Option<&str>, node_type: Option, limit: usize) -> Result>; + + // Child ID sequencing + async fn next_child_seq(&self, parent_id: &str) -> Result; +} + +pub enum EdgeDirection { Outgoing, Incoming, Both } + +pub struct WorkGraph { + pub nodes: Vec, + pub edges: Vec, +} + +pub struct NodeQuery { + pub node_type: Option, + pub status: Option, + pub project_id: Option, + pub parent_id: Option, + pub query: Option, +} +``` + +**Verification:** +Run: `cargo check` +Expected: Compiles (trait is unused for now, that's fine) + +**Commit:** `feat(graph): GraphStore trait definition` + + + + +### Task 4: SqliteGraphStore — node and edge CRUD + +**Verifies:** P1b.AC3.1, P1b.AC3.2, P1b.AC3.3, P1b.AC3.4, P1b.AC3.5, P1b.AC3.6 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` — add `SqliteGraphStore` implementing `GraphStore` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_store_test.rs` + +**Implementation:** + +`SqliteGraphStore` struct wrapping `Database`: + +- `new(db: Database) -> Self` +- Implement all `GraphStore` trait methods using `db.connection().call(...)` with `BEGIN IMMEDIATE` for writes. + +Key implementation details: + +- **create_node**: INSERT into `nodes` table. Serialize `labels` as JSON array, `metadata` as JSON object. If the node has a parent (determined by `parent_id()` helper), call `next_child_seq` first to get the sequence number. Store `next_child_seq` in the parent's metadata atomically (read current seq, increment, update parent metadata, insert child — all in one `BEGIN IMMEDIATE` transaction). + +- **update_node**: UPDATE with optional fields. Only set columns that are `Some`. Validate status against node_type before updating. + +- **get_node**: SELECT by id. Deserialize labels from JSON array, metadata from JSON object. + +- **add_edge**: INSERT into edges. Validate that both from_node and to_node exist. + +- **get_edges**: SELECT edges + JOIN nodes based on direction (Outgoing: from_node = id; Incoming: to_node = id; Both: either). + +- **get_children**: SELECT nodes joined via edges WHERE edge_type = 'contains' AND from_node = parent_id. + +- **get_subtree**: Recursive CTE (`WITH RECURSIVE`) walking Contains edges downward from the given node. + +- **get_active_decisions**: Query Decision nodes for the project where status is Active or Decided. "Now mode" — returns the current truth (active decisions only, no abandoned/superseded). + +- **get_full_graph**: Get the full subtree of nodes under a goal, plus all edges involving those nodes. Returns a `WorkGraph` struct containing both nodes and edges. "History mode" — includes abandoned paths and superseded decisions. + +- **next_child_seq**: Read parent node's `metadata.next_child_seq` (default 1 if absent), increment it, update parent metadata, return the old value. All in one `BEGIN IMMEDIATE` transaction. + +Row-to-struct mapping: implement a helper function `fn row_to_node(row: &rusqlite::Row) -> rusqlite::Result` that maps column indices to struct fields. Same for `row_to_edge`. + +**Testing:** + +Tests must verify each AC: +- P1b.AC3.1: Create a goal node, verify it's retrievable +- P1b.AC3.2: `get_node` returns None for nonexistent ID +- P1b.AC3.3: Create node as Pending, update to Active, verify status changed +- P1b.AC3.4: Create two nodes, add Contains edge, verify `get_edges(Outgoing)` returns it +- P1b.AC3.5: Create goal + 2 child tasks via Contains edges, verify `get_children` returns both +- P1b.AC3.6: Create goal -> task -> subtask chain, verify `get_subtree(goal_id)` returns all 3 +- `get_active_decisions`: Create 2 decisions under a project — one Active, one Superseded. Verify `get_active_decisions` returns only the Active one. +- `get_full_graph`: Create a goal with tasks and edges. Verify `get_full_graph` returns a `WorkGraph` containing all nodes and edges. + +Use `Database::open_in_memory()`. Create a helper to build test nodes with sensible defaults. + +**Verification:** +Run: `cargo test graph_store_test` +Expected: All tests pass + +**Commit:** `feat(graph): SqliteGraphStore with node/edge CRUD, subtree queries` + + + + + + + +### Task 5: Dependency resolution and task surfacing + +**Verifies:** P1b.AC4.1, P1b.AC4.2, P1b.AC4.3 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/dependency.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` — implement `get_ready_tasks` and `get_next_task` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_dependency_test.rs` + +**Implementation:** + +`src/graph/dependency.rs`: +- `pub fn check_dependencies_met(conn: &rusqlite::Connection, node_id: &str) -> rusqlite::Result` — query all DependsOn edges from this node, check if all target nodes have status Completed. Returns true if all deps are met (or no deps exist). + +In `SqliteGraphStore`: +- **get_ready_tasks**: Query task nodes under goal where status = 'ready'. A task is Ready when it was moved there by the status update logic (see below). +- **get_next_task**: From ready tasks, sort by: (1) priority (Critical > High > Medium > Low), (2) downstream count (COUNT of nodes that transitively DependsOn this task), (3) break ties by created_at. Return the first. + +Status transition hook: When `update_node` completes a task (status -> Completed), scan all nodes that DependsOn it. For each, if all DependsOn targets are now Completed and current status is Pending, update to Ready. This runs within the same `BEGIN IMMEDIATE` transaction as the status update. + +**Testing:** + +Tests must verify: +- P1b.AC4.1: Create task A (Pending) and task B (Pending, DependsOn A). Complete A. Verify B is now Ready. +- P1b.AC4.2: Create 3 tasks under a goal — one Ready, one Pending (dep not met), one Completed. `get_ready_tasks` returns only the Ready one. +- P1b.AC4.3: Create 2 Ready tasks — one High priority blocking 3 downstream tasks, one Critical priority blocking 0. `get_next_task` returns the Critical one (priority wins over downstream count). + +**Verification:** +Run: `cargo test graph_dependency_test` +Expected: All tests pass + +**Commit:** `feat(graph): dependency resolution, ready surfacing, next task recommendation` + + + + +### Task 6: Atomic task claiming and FTS5 search + +**Verifies:** P1b.AC5.1, P1b.AC5.2, P1b.AC6.1, P1b.AC6.2 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` — implement `claim_task` and `search_nodes` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_claim_search_test.rs` + +**Implementation:** + +**claim_task**: Single conditional UPDATE within `BEGIN IMMEDIATE`: +```sql +UPDATE nodes SET status = 'claimed', assigned_to = ?1, started_at = ?2 +WHERE id = ?3 AND status = 'ready'; +``` +Check `conn.changes() == 1`. If so, return `Ok(true)`. If 0, return `Ok(false)`. + +**search_nodes**: FTS5 MATCH query: +```sql +SELECT n.* FROM nodes n +JOIN nodes_fts fts ON n.rowid = fts.rowid +WHERE nodes_fts MATCH ?1 +``` +Add optional WHERE clauses for `project_id` and `node_type` filters. Add `LIMIT` clause. + +Note: The FTS5 sync triggers (created in Phase 1a schema) keep `nodes_fts` in sync automatically. No application code needed for sync. + +**Testing:** + +Tests must verify: +- P1b.AC5.1: Create a Ready task, `claim_task(id, "agent-1")` returns true. Node now has status Claimed and assigned_to = "agent-1". +- P1b.AC5.2: Create a Ready task, claim it once (true), claim it again (false — already claimed). +- P1b.AC6.1: Create nodes with various titles/descriptions. `search_nodes("authentication")` returns nodes containing that term. +- P1b.AC6.2: Create nodes in different projects. Search with `project_id` filter returns only nodes from that project. Create nodes of different types (e.g., Task and Observation). Search with `node_type` filter returns only nodes of that type. + +**Verification:** +Run: `cargo test graph_claim_search_test` +Expected: All tests pass + +**Commit:** `feat(graph): atomic task claiming and FTS5 full-text search` + + + + + + + +### Task 7: Graph tools for agents — low-level and high-level + +**Verifies:** P1b.AC7.1, P1b.AC7.2 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tools/graph_tools.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tools/mod.rs` — add `pub mod graph_tools;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_tools_test.rs` + +**Implementation:** + +Each tool implements the existing `Tool` trait (async_trait, name/description/parameters/execute). Tools receive a `SqliteGraphStore` (via `Arc`) at construction time. + +**Low-level tools** (thin wrappers around GraphStore): +- `CreateNodeTool` — params: `{ node_type, title, description, parent_id?, priority?, metadata? }`. If `parent_id` is given, generates child ID using `next_child_seq`. If not, generates goal ID. Creates the Contains edge if parent exists. +- `UpdateNodeTool` — params: `{ node_id, status?, title?, description?, metadata? }`. Validates status transitions. +- `AddEdgeTool` — params: `{ edge_type, from_node, to_node, label? }`. Generates edge ID. +- `QueryNodesTool` — params: `{ node_type?, status?, project_id?, parent_id?, query? }`. Returns JSON array of matching nodes. +- `SearchNodesTool` — params: `{ query, project_id?, node_type?, limit? }`. FTS5 search, returns JSON results. + +**High-level tools** (workflow shortcuts composing multiple store operations): +- `ClaimTaskTool` — params: `{ node_id }`. Calls `claim_task`. +- `LogDecisionTool` — params: `{ title, description, options: [{ title, description, pros?, cons? }], parent_id? }`. Creates Decision node + Option nodes + LeadsTo edges in one call. +- `ChooseOptionTool` — params: `{ decision_id, option_id, rationale }`. Adds Chosen edge, Rejected edges to other options, updates Decision status to Decided. +- `RecordOutcomeTool` — params: `{ parent_id, title, description, success }`. Creates Outcome node + LeadsTo edge. +- `RecordObservationTool` — params: `{ title, description, related_node_id? }`. Creates Observation node + Informs edge if related_node_id given. +- `RevisitTool` — params: `{ outcome_id, reason, new_decision_title? }`. Creates Revisit node + LeadsTo edge. If new_decision_title given, creates new Decision node + LeadsTo edge from Revisit. + +Each tool's `parameters()` method returns a JSON schema describing its params. +Each tool's `execute()` method parses params from `serde_json::Value`, calls the store, and returns a JSON string result. + +**Testing:** + +Tests must verify: +- P1b.AC7.1: Create a node via `CreateNodeTool::execute()`, verify it exists in the store. Same for update, add_edge, query, search. +- P1b.AC7.2: Use `LogDecisionTool` to create a decision with 2 options, verify Decision + 2 Option nodes + 2 LeadsTo edges created. Use `ChooseOptionTool` to pick one, verify Chosen/Rejected edges and status updates. Use `RecordOutcomeTool`, verify Outcome + LeadsTo edge. Use `RecordObservationTool`, verify Observation + Informs edge. + +Use `Database::open_in_memory()` and construct tools with `Arc`. + +**Verification:** +Run: `cargo test graph_tools_test` +Expected: All tests pass + +**Commit:** `feat(tools): graph tools for agents — create, update, query, search, claim, decision workflow` + + + + +### Task 8: Wire up graph CLI commands + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add `Tasks`, `Decisions`, `Status`, `Search` subcommands + +**Implementation:** + +Add new `Commands` variants: + +```rust +/// View and manage tasks +Tasks { + #[command(subcommand)] + action: Option, +}, +/// View and manage decisions +Decisions { + #[command(subcommand)] + action: Option, +}, +/// Show project status +Status, +/// Search graph nodes +Search { + /// Search query + query: String, +}, +``` + +```rust +#[derive(Subcommand)] +enum TaskAction { + /// List all tasks (filterable) + List { + #[arg(long)] status: Option, + #[arg(long)] priority: Option, + }, + /// Show ready tasks + Ready, + /// Recommend next task + Next, + /// Show task tree + Tree, +} + +#[derive(Subcommand)] +enum DecisionAction { + /// List active decisions + List, + /// Current truth — active decisions only + Now, + /// Full evolution including abandoned paths + History, + /// Show decision details + Show { id: String }, +} +``` + +Each command: +1. Resolves project via `--project` flag or cwd +2. Opens database, creates `SqliteGraphStore` +3. Calls the appropriate store method +4. Formats and prints results + +Keep formatting simple — structured text output. Fancy formatting is not a priority. + +**Verification:** + +Run: `cargo build` +Expected: Compiles cleanly + +Run: `cargo run -- tasks --help` +Expected: Shows task subcommands (list, ready, next, tree) + +Run: `cargo run -- decisions --help` +Expected: Shows decision subcommands (list, now, history, show) + +**Commit:** `feat(cli): tasks, decisions, status, and search commands` + + + + + +### Task 9: Concurrency test — multiple claim_task attempts + +**Verifies:** P1b.AC5.1, P1b.AC5.2 (concurrency aspect) + +**Files:** +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_concurrency_test.rs` + +**Implementation:** + +Write a concurrency test that spawns multiple tokio tasks all trying to claim the same Ready task simultaneously. Verify that exactly one succeeds and the rest get false. + +```rust +#[tokio::test] +async fn test_concurrent_task_claiming() { + // Setup: create a goal with one Ready task + // Spawn 10 tokio tasks all calling claim_task for the same node + // Collect results + // Assert exactly 1 true, 9 false +} +``` + +This tests the atomicity guarantee of the conditional UPDATE under concurrent access through the single tokio-rusqlite connection. + +**Testing:** + +- Exactly one of N concurrent claim attempts succeeds +- The task ends up with status Claimed and a single assigned_to + +**Verification:** +Run: `cargo test graph_concurrency_test` +Expected: All tests pass + +**Commit:** `test(graph): concurrent task claiming atomicity` + + diff --git a/docs/implementation-plans/2026-02-07-v2-phase1/phase_03.md b/docs/implementation-plans/2026-02-07-v2-phase1/phase_03.md new file mode 100644 index 0000000..38be4cb --- /dev/null +++ b/docs/implementation-plans/2026-02-07-v2-phase1/phase_03.md @@ -0,0 +1,482 @@ +# Rustagent V2 Phase 1c: Sessions + Export + Interchange + +**Goal:** Implement session management with deterministic handoff notes, ADR export to markdown, TOML graph import/export/diff, node decay for context injection, and associated CLI commands. + +**Architecture:** Sessions are temporal records (not graph nodes) tracking work periods per goal. Handoff notes are template-generated from graph state queries — no LLM call. TOML interchange uses one file per goal in `.rustagent/graph/`, with deterministic key ordering (BTreeMap) for git-friendly diffs. Node decay compacts old nodes for context injection based on configurable age thresholds. + +**Tech Stack:** Rust (edition 2024), rusqlite 0.32 (bundled), tokio-rusqlite 0.6, toml 0.8, blake3 1.x, chrono, serde/serde_json, anyhow + +**Scope:** Phase 3 of 4 from the v2 architecture (Phase 1c: Sessions + Export + Interchange) + +**Codebase verified:** 2026-02-07 + +**Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` + +**Depends on:** Phase 1a (database), Phase 1b (graph store, node types) + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### P1c.AC1: Session management +- **P1c.AC1.1 Success:** `create_session(goal_id)` creates a session record with start time and goal reference +- **P1c.AC1.2 Success:** `end_session(session_id)` generates deterministic handoff notes from graph state and stores them +- **P1c.AC1.3 Success:** Handoff notes contain Done, Remaining, Blocked, and Decisions Made sections populated from graph queries +- **P1c.AC1.4 Success:** `get_latest_session(goal_id)` returns the most recent session with handoff notes + +### P1c.AC2: ADR export +- **P1c.AC2.1 Success:** `export_adrs(project_id, output_dir)` generates numbered markdown files (001-xxx.md) from Decision nodes +- **P1c.AC2.2 Success:** Each ADR contains Status, Context, Options Considered (with Chosen/Rejected labels, pros/cons), Outcome, and Related Tasks sections + +### P1c.AC3: TOML graph interchange +- **P1c.AC3.1 Success:** `export_goal(goal_id)` produces a TOML file matching the format in the architecture (meta, nodes, edges sections) +- **P1c.AC3.2 Success:** Output is deterministic — re-exporting unchanged state produces byte-identical output (sorted keys, omitted null fields) +- **P1c.AC3.3 Success:** `import_goal(toml_content, strategy)` imports nodes and edges with merge/theirs/ours conflict strategies +- **P1c.AC3.4 Success:** Round-trip: export -> import -> export produces identical files +- **P1c.AC3.5 Success:** `diff_goal(toml_content, goal_id)` shows added/changed/unchanged entities +- **P1c.AC3.6 Success:** Cross-goal edge references to nonexistent nodes are skipped with a clear error message + +### P1c.AC4: Node decay +- **P1c.AC4.1 Success:** Nodes < 7 days old: full detail (description, criteria, outcomes) +- **P1c.AC4.2 Success:** Nodes 7-30 days old: summary only (title, status, key outcome) +- **P1c.AC4.3 Success:** Nodes > 30 days old: minimal (title, status) +- **P1c.AC4.4 Success:** Thresholds are configurable + +### P1c.AC5: CLI commands +- **P1c.AC5.1 Success:** `rustagent sessions` lists sessions for current goal +- **P1c.AC5.2 Success:** `rustagent sessions latest` shows most recent handoff notes +- **P1c.AC5.3 Success:** `rustagent decisions export` writes ADR markdown files +- **P1c.AC5.4 Success:** `rustagent graph export` / `rustagent graph import` / `rustagent graph diff` work + +--- + + + + +### Task 1: Session management and handoff notes + +**Verifies:** P1c.AC1.1, P1c.AC1.2, P1c.AC1.3, P1c.AC1.4 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/session.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod session;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/session_test.rs` + +**Implementation:** + +`src/graph/session.rs`: + +```rust +pub struct Session { + pub id: String, + pub project_id: String, + pub goal_id: String, + pub started_at: DateTime, + pub ended_at: Option>, + pub handoff_notes: Option, + pub agent_ids: Vec, // JSON serialized + pub summary: Option, +} +``` + +`SessionStore` wrapping `Database`: +- `async fn create_session(&self, project_id: &str, goal_id: &str) -> Result` — generates ID, inserts with `BEGIN IMMEDIATE` +- `async fn end_session(&self, session_id: &str, graph_store: &SqliteGraphStore) -> Result<()>` — calls `generate_handoff_notes`, updates ended_at and handoff_notes +- `async fn get_latest_session(&self, goal_id: &str) -> Result>` — SELECT ORDER BY started_at DESC LIMIT 1 +- `async fn list_sessions(&self, goal_id: &str) -> Result>` + +`fn generate_handoff_notes(conn: &rusqlite::Connection, goal_id: &str) -> rusqlite::Result`: +Template-based, queries graph state: + +``` +## Done +{for each node under goal with status Completed or Decided} +- {id}: {title} ({status}) + +## Remaining +{for each node under goal with status Ready, Pending, or InProgress} +- {id}: {title} ({status}{, blocked by X if blocked}) + +## Blocked +{for each node under goal with status Blocked} +- {id}: {title} — {blocked_reason} + +## Decisions Made +{for each Decision node under goal with status Decided} +- {id}: {title} → {chosen option title} ({rationale from Chosen edge label}) +``` + +This runs within the `end_session` call's `BEGIN IMMEDIATE` transaction using direct SQL queries on the raw `rusqlite::Connection` (not the async `GraphStore` methods — it's inside the `.call()` closure where only synchronous rusqlite is available). The queries duplicate some of what `SqliteGraphStore` does but operate directly on the connection for transactional consistency. + +**Testing:** + +Tests must verify: +- P1c.AC1.1: `create_session` returns a session with valid ID and start time +- P1c.AC1.2: After creating tasks (some completed, some pending) and a decided decision under a goal, `end_session` produces handoff notes +- P1c.AC1.3: Handoff notes contain all 4 sections with correct content from the graph +- P1c.AC1.4: `get_latest_session` returns the most recent of 2 sessions + +Set up test data by creating nodes and edges via `SqliteGraphStore` before calling session functions. + +**Verification:** +Run: `cargo test session_test` +Expected: All tests pass + +**Commit:** `feat(graph): session management with deterministic handoff notes` + + + + +### Task 2: ADR export + +**Verifies:** P1c.AC2.1, P1c.AC2.2 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/export.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod export;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/adr_export_test.rs` + +**Implementation:** + +`src/graph/export.rs`: + +`pub async fn export_adrs(graph_store: &SqliteGraphStore, project_id: &str, output_dir: &Path) -> Result>`: +1. Query all Decision nodes for the project (any status — Active, Decided, Superseded) +2. For each Decision, gather connected Options (via LeadsTo edges), their Chosen/Rejected status (via Chosen/Rejected edges), and any Outcome nodes (via LeadsTo from related tasks) +3. Number sequentially (001, 002, ...) ordered by created_at +4. Generate markdown per the architecture format (lines 583-596 of design doc) +5. Write to `output_dir/001-.md` +6. Return list of written file paths + +Title slugification: lowercase, replace spaces with hyphens, strip non-alphanumeric except hyphens. + +**Testing:** + +Tests must verify: +- P1c.AC2.1: Create 2 decisions in a project. Export to a tempdir. Two files created: `001-*.md` and `002-*.md`. +- P1c.AC2.2: Open the exported file. Verify it contains Status, Context, Options Considered (with CHOSEN/REJECTED labels and pros/cons), and Related Tasks sections. + +**Verification:** +Run: `cargo test adr_export_test` +Expected: All tests pass + +**Commit:** `feat(graph): ADR export to markdown` + + + + + + + +### Task 3: TOML interchange — export + +**Verifies:** P1c.AC3.1, P1c.AC3.2 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/Cargo.toml` — add `blake3` dependency +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/interchange.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod interchange;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/interchange_test.rs` + +**Prerequisite:** Add `blake3 = "1"` to `[dependencies]` in Cargo.toml. Run `cargo check` to verify. + +**Implementation:** + +`src/graph/interchange.rs`: + +Define serde types for the TOML format (separate from the DB types to control serialization): + +```rust +#[derive(Serialize, Deserialize)] +struct GoalFile { + meta: Meta, + nodes: BTreeMap, // BTreeMap for sorted keys + edges: BTreeMap, +} + +#[derive(Serialize, Deserialize)] +struct Meta { + version: u32, + goal_id: String, + project: String, + exported_at: String, + content_hash: String, +} +``` + +`TomlNode` and `TomlEdge` are simplified serde structs that map to the TOML format shown in the architecture (lines 726-814). Use `#[serde(skip_serializing_if = "Option::is_none")]` to omit null fields. + +`pub async fn export_goal(graph_store: &SqliteGraphStore, goal_id: &str, project_name: &str) -> Result`: +1. Get subtree of goal node (all descendants) +2. Get all edges where either from_node or to_node is in the subtree +3. Convert to `GoalFile` with BTreeMap for deterministic ordering +4. Compute content hash: blake3 hash of serialized nodes+edges (excluding meta) +5. Serialize with `toml::to_string_pretty()` +6. Return the TOML string + +Use BTreeMap (not HashMap) for the nodes and edges maps — toml crate's Map type is BTreeMap by default, which gives sorted key order. + +**Testing:** + +Tests must verify: +- P1c.AC3.1: Create a goal with tasks, decisions, options, edges. Export. Parse the TOML string. Verify meta, nodes, and edges sections exist with correct data. +- P1c.AC3.2: Export twice without changes. Both strings are byte-identical. + +**Verification:** +Run: `cargo test interchange_test` +Expected: All tests pass + +**Commit:** `feat(graph): TOML export for goal files` + + + + +### Task 4: TOML interchange — import with conflict strategies + +**Verifies:** P1c.AC3.3, P1c.AC3.4, P1c.AC3.6 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/interchange.rs` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/interchange_test.rs` (add more tests) + +**Implementation:** + +```rust +pub enum ImportStrategy { Merge, Theirs, Ours } + +pub struct ImportResult { + pub added_nodes: usize, + pub added_edges: usize, + pub conflicts: Vec, + pub skipped_edges: Vec, // Cross-goal refs to nonexistent nodes + pub unchanged: usize, +} + +pub struct ImportConflict { + pub node_id: String, + pub field: String, + pub db_value: String, + pub file_value: String, +} +``` + +`pub async fn import_goal(graph_store: &SqliteGraphStore, toml_content: &str, strategy: ImportStrategy) -> Result`: +1. Parse TOML into `GoalFile` +2. For each node: check if it exists in DB + - New node: insert + - Existing, unchanged: skip + - Existing, changed: apply strategy (Merge=flag conflict, Theirs=file wins, Ours=skip) +3. For each edge: check if both from_node and to_node exist in DB + - Both exist: insert edge (idempotent — skip if edge already exists) + - Target node missing: add to `skipped_edges` with clear message +4. Return `ImportResult` + +All writes in one `BEGIN IMMEDIATE` transaction. + +**Testing:** + +Tests must verify: +- P1c.AC3.3: Export a goal, modify a node in the TOML string, import with `Theirs` strategy. Verify DB has the modified value. +- P1c.AC3.4: Export, import into a fresh DB, export again. Both TOML strings are identical. +- P1c.AC3.6: Create an edge in TOML referencing a nonexistent node ID. Import. Edge is skipped and appears in `skipped_edges` with a descriptive message. + +**Verification:** +Run: `cargo test interchange_test` +Expected: All tests pass + +**Commit:** `feat(graph): TOML import with merge/theirs/ours conflict resolution` + + + + +### Task 5: TOML interchange — diff + +**Verifies:** P1c.AC3.5 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/interchange.rs` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/interchange_test.rs` (add diff test) + +**Implementation:** + +```rust +pub struct DiffResult { + pub added_nodes: Vec, + pub changed_nodes: Vec<(String, Vec)>, // (id, changed_fields) + pub removed_nodes: Vec, // in DB but not in file + pub added_edges: Vec, + pub removed_edges: Vec, + pub unchanged_nodes: usize, + pub unchanged_edges: usize, +} +``` + +`pub async fn diff_goal(graph_store: &SqliteGraphStore, toml_content: &str) -> Result`: +1. Parse TOML +2. Compare each node/edge against DB state +3. Report differences without making any changes + +**Testing:** + +- P1c.AC3.5: Create a goal in DB. Export to TOML. Add a node and change another in the TOML string. Diff. Verify `added_nodes` has 1 entry, `changed_nodes` has 1 entry with the changed fields listed. + +**Verification:** +Run: `cargo test interchange_test` +Expected: All tests pass + +**Commit:** `feat(graph): TOML diff against DB state` + + + + + + + +### Task 6: Node decay for context injection + +**Verifies:** P1c.AC4.1, P1c.AC4.2, P1c.AC4.3, P1c.AC4.4 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/decay.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod decay;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/decay_test.rs` + +**Implementation:** + +`src/graph/decay.rs`: + +```rust +pub struct DecayConfig { + pub recent_days: i64, // Default: 7 + pub older_days: i64, // Default: 30 +} + +impl Default for DecayConfig { + fn default() -> Self { + Self { recent_days: 7, older_days: 30 } + } +} + +pub enum DecayLevel { Full, Summary, Minimal } + +pub struct DecayedNode { + pub id: String, + pub title: String, + pub status: NodeStatus, + pub detail: DecayDetail, +} + +pub enum DecayDetail { + Full { description: String, metadata: HashMap }, + Summary { key_outcome: Option }, + Minimal, +} +``` + +`pub fn decay_node(node: &GraphNode, now: DateTime, config: &DecayConfig) -> DecayedNode`: +- Calculate age from `completed_at` (or `created_at` if not completed) +- If age < `recent_days`: Full detail +- If age < `older_days`: Summary — title, status, extract key outcome from metadata if present +- Else: Minimal — title and status only + +`pub fn decay_nodes(nodes: &[GraphNode], now: DateTime, config: &DecayConfig) -> Vec`: +- Apply `decay_node` to each + +**Testing:** + +Tests must verify: +- P1c.AC4.1: Node completed 2 days ago → DecayDetail::Full with description and metadata +- P1c.AC4.2: Node completed 15 days ago → DecayDetail::Summary with title and status +- P1c.AC4.3: Node completed 45 days ago → DecayDetail::Minimal with title and status only +- P1c.AC4.4: Custom config with `recent_days: 3, older_days: 10`. Node completed 5 days ago → Summary (not Full). + +**Verification:** +Run: `cargo test decay_test` +Expected: All tests pass + +**Commit:** `feat(graph): node decay for context injection with configurable thresholds` + + + + +### Task 7: Wire up session, export, and interchange CLI commands + +**Verifies:** P1c.AC5.1, P1c.AC5.2, P1c.AC5.3, P1c.AC5.4 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add `Sessions`, `Graph` subcommands, extend `Decisions` with `Export` action + +**Implementation:** + +Add CLI variants: + +```rust +/// View sessions and handoff notes +Sessions { + #[command(subcommand)] + action: Option, +}, +/// Import/export graph data +Graph { + #[command(subcommand)] + action: GraphAction, +}, +``` + +```rust +#[derive(Subcommand)] +enum SessionAction { + /// List sessions for current goal + List { #[arg(long)] goal: Option }, + /// Show most recent handoff notes + Latest { #[arg(long)] goal: Option }, +} + +#[derive(Subcommand)] +enum GraphAction { + /// Export goals to TOML files + Export { + #[arg(long)] goal: Option, + #[arg(long)] output: Option, + }, + /// Import TOML files + Import { + path: String, + #[arg(long)] dry_run: bool, + #[arg(long)] theirs: bool, + #[arg(long)] ours: bool, + }, + /// Diff TOML file against DB + Diff { path: String }, +} +``` + +Add `Export` variant to `DecisionAction`: +```rust +/// Export decisions as ADR markdown files +Export { + #[arg(long)] output: Option, +}, +``` + +Each command resolves project, opens DB, creates stores, calls functions, prints results. + +**Note:** Session TOML export (`.rustagent/sessions/*.toml` files as described in the architecture) is deferred to a later phase. This phase covers session DB records and handoff notes generation, but not the file-based session export format. + +**Verification:** + +Run: `cargo build` +Expected: Compiles cleanly + +Run: `cargo run -- sessions --help` +Expected: Shows list/latest subcommands + +Run: `cargo run -- graph --help` +Expected: Shows export/import/diff subcommands + +**Commit:** `feat(cli): sessions, graph interchange, and ADR export commands` + + + diff --git a/docs/implementation-plans/2026-02-07-v2-phase1/phase_04.md b/docs/implementation-plans/2026-02-07-v2-phase1/phase_04.md new file mode 100644 index 0000000..bacb68a --- /dev/null +++ b/docs/implementation-plans/2026-02-07-v2-phase1/phase_04.md @@ -0,0 +1,576 @@ +# Rustagent V2 Phase 1d: Agent Runtime + Single-Agent Execution + +**Goal:** Cherry-pick existing modules (LLM, security, tools), define the Agent trait and AgentProfile system, refactor the Ralph loop into a generic AgentRuntime with error handling (confusion counter, token budget), implement profile resolution, and wire up `rustagent run` for single-agent execution. + +**Architecture:** The AgentRuntime is a generic agentic loop (LLM call -> tool execution -> repeat) that replaces the v1 Ralph loop. It's parameterized by an AgentProfile (which controls system prompt, allowed tools, security scope, LLM config) and an AgentContext (task details, decisions, handoff notes). Error handling adds a confusion counter for bad tool calls, configurable consecutive failure thresholds, and per-worker token budget tracking. + +**Tech Stack:** Rust (edition 2024), async-trait, tokio, serde/serde_json, chrono, uuid, anyhow, walkdir, glob + +**Scope:** Phase 4 of 4 from the v2 architecture (Phase 1d: Agent Runtime + Single-Agent Execution) + +**Codebase verified:** 2026-02-07 + +**Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` + +**Depends on:** Phase 1a (database), Phase 1b (graph store, graph tools), Phase 1c (sessions, context decay) + +**Deferred to later phases:** +- `src/config/autonomy.rs` (AutonomyLevel, ApprovalGate types) — architecture Phase 5. The `run` command in this phase does not support `--autonomy` flag. Autonomy enforcement requires the orchestrator (Phase 2) and approval gate system (Phase 5). +- `src/tools/search.rs` (code search tool) — architecture Phase 5. The `search` CLI command in Phase 1b covers FTS5 graph node search only. File content search for agents is deferred. +- `pulldown-cmark` (AGENTS.md parsing) — simple string-based heading extraction is sufficient for Phase 1d. Full markdown parsing deferred if needed. +- `tokio-util` (CancellationToken) — `Agent::cancel()` is a no-op stub in Phase 1d (single-agent mode). CancellationToken integration deferred to Phase 2 (multi-agent orchestration). + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### P1d.AC1: Cherry-pick and adapt existing modules +- **P1d.AC1.1 Success:** LLM module (`src/llm/`) compiles in new structure with no TUI dependencies +- **P1d.AC1.2 Success:** Security module (`src/security/`) compiles with new `SecurityScope` type added +- **P1d.AC1.3 Success:** Tools module (`src/tools/`) compiles with graph_tools integrated into the registry + +### P1d.AC2: Agent trait and types +- **P1d.AC2.1 Success:** `Agent` trait defined with `id()`, `profile()`, `run(ctx) -> AgentOutcome`, `cancel()` +- **P1d.AC2.2 Success:** `AgentContext` struct contains task details, relevant decisions, handoff notes, AGENTS.md summaries, profile +- **P1d.AC2.3 Success:** `AgentOutcome` enum covers Completed, Blocked, Failed, TokenBudgetExhausted + +### P1d.AC3: Agent profiles +- **P1d.AC3.1 Success:** `AgentProfile` struct with name, extends, role, system_prompt, allowed_tools, security, llm config, turn_limit, token_budget +- **P1d.AC3.2 Success:** 5 built-in profiles defined: planner, coder, reviewer, tester, researcher +- **P1d.AC3.3 Success:** Custom profiles loaded from `.rustagent/profiles/*.toml` (project-level) and `~/.config/rustagent/profiles/*.toml` (user-level) +- **P1d.AC3.4 Success:** Profile resolution: project-level > user-level > built-in. First match wins. +- **P1d.AC3.5 Success:** Inheritance via `extends`: scalar fields replaced, list fields replaced, system_prompt appended + +### P1d.AC4: AgentRuntime (agentic loop) +- **P1d.AC4.1 Success:** AgentRuntime runs the LLM call -> tool execution -> repeat loop +- **P1d.AC4.2 Success:** Confusion counter: after N consecutive bad tool calls (configurable), worker signals blocked +- **P1d.AC4.3 Success:** Token budget: at warning threshold (default 80%), injects "wrap up" system message. At 100%, force-stops with partial completion. +- **P1d.AC4.4 Success:** Consecutive LLM failure threshold: after N failures (configurable), worker signals blocked +- **P1d.AC4.5 Success:** Turn limit: worker stops after max turns with partial completion report + +### P1d.AC5: Context assembly +- **P1d.AC5.1 Success:** ContextBuilder assembles compact structured context from task details, decisions, handoff notes, observations, AGENTS.md +- **P1d.AC5.2 Success:** AGENTS.md files resolved by closest-to-file rule per agents.md spec + +### P1d.AC6: Single-agent CLI +- **P1d.AC6.1 Success:** `rustagent run --project ""` creates a goal node, creates a session, runs a single coder agent, and records the outcome +- **P1d.AC6.2 Success:** Profile selection via `--profile ` flag (defaults to coder) + +--- + + +### Task 1: Clean up cherry-picked modules for v2 compatibility + +**Verifies:** P1d.AC1.1, P1d.AC1.2, P1d.AC1.3 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/ralph/mod.rs` — remove `use crate::tui::messages::{AgentMessage, AgentSender};` and the `run_with_sender` and `execute_task_with_sender` methods (TUI removed in Phase 1a Task 2) +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tools/factory.rs` — add `SqliteGraphStore` parameter, register graph tools alongside existing tools +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/security/mod.rs` — no changes needed yet (SecurityScope added in Task 3) + +**Implementation:** + +In `ralph/mod.rs`: +- Remove the import of `crate::tui::messages` +- Remove the `run_with_sender` method entirely +- Remove the `execute_task_with_sender` method entirely +- The remaining `run` and `execute_task` methods stay as-is — they'll be replaced by AgentRuntime in Task 5, but the v1 `Run` command should still work in the meantime. + +In `tools/factory.rs`: +- Add a new function `create_v2_registry(validator, permission_handler, graph_store)` that creates the default registry AND registers all graph tools from `graph_tools.rs`. Keep the existing `create_default_registry` for backward compatibility with v1 commands. +- The graph tools need `Arc` passed in, and the agent's ID for tools that need it (like `claim_task`). + +**Verification:** + +Run: `cargo check` +Expected: Compiles cleanly + +Run: `cargo test` +Expected: All existing tests still pass + +**Commit:** `refactor: clean up modules for v2 compatibility, add v2 tool registry factory` + + + + + + +### Task 2: Agent trait and AgentOutcome + +**Verifies:** P1d.AC2.1, P1d.AC2.3 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/mod.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod agent;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/agent_types_test.rs` + +**Implementation:** + +`src/agent/mod.rs`: + +```rust +pub type AgentId = String; + +#[async_trait] +pub trait Agent: Send + Sync { + fn id(&self) -> &AgentId; + fn profile(&self) -> &AgentProfile; + async fn run(&self, ctx: AgentContext) -> Result; + fn cancel(&self); // No-op stub in Phase 1d (single-agent). CancellationToken integration deferred to Phase 2. +} + +pub enum AgentOutcome { + Completed { summary: String }, + Blocked { reason: String }, + Failed { error: String }, + TokenBudgetExhausted { summary: String, tokens_used: usize }, +} + +pub struct AgentContext { + pub work_package_tasks: Vec, + pub relevant_decisions: Vec, + pub handoff_notes: Option, + pub agents_md_summaries: Vec<(String, String)>, // (path, heading summary) + pub profile: AgentProfile, + pub project_path: PathBuf, + pub graph_store: Arc, +} +``` + +Declare sub-modules: `pub mod profile;`, `pub mod runtime;`, `pub mod builtin_profiles;` + +**Testing:** + +- P1d.AC2.1: Verify Agent trait compiles (it's a trait, so just verify a mock can implement it) +- P1d.AC2.3: Verify AgentOutcome variants can be constructed and matched + +**Verification:** +Run: `cargo test agent_types_test` +Expected: All tests pass + +**Commit:** `feat(agent): Agent trait, AgentId, AgentContext, AgentOutcome types` + + + + +### Task 3: AgentProfile type and SecurityScope + +**Verifies:** P1d.AC3.1, P1d.AC1.2 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/profile.rs` +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/security/scope.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/security/mod.rs` — add `pub mod scope;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/profile_test.rs` + +**Implementation:** + +`src/security/scope.rs`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityScope { + pub allowed_paths: Vec, + pub denied_paths: Vec, + pub allowed_commands: Vec, + pub read_only: bool, + pub can_create_files: bool, + pub network_access: bool, +} +``` + +Implement `Default` with permissive defaults (all paths allowed, not read-only, etc.) so built-in profiles can override specific fields. + +`src/agent/profile.rs`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentProfile { + pub name: String, + pub extends: Option, + pub role: String, + pub system_prompt: String, + pub allowed_tools: Vec, + pub security: SecurityScope, + #[serde(default)] + pub llm: ProfileLlmConfig, + pub turn_limit: Option, + pub token_budget: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProfileLlmConfig { + pub model: Option, + pub temperature: Option, + pub max_tokens: Option, +} +``` + +Implement `AgentProfile::apply_inheritance(&mut self, parent: &AgentProfile)`: +1. For scalar fields: only override if `self` has a meaningful value (non-empty string, Some, etc.) +2. For list fields (allowed_tools, allowed_paths, etc.): child replaces parent entirely (not merged) +3. For system_prompt: append child to parent with `\n\n## Project-Specific Instructions\n` separator +4. For optional fields (turn_limit, token_budget, llm): child Some wins, falls through to parent if None + +**Testing:** + +Tests must verify: +- P1d.AC3.1: AgentProfile deserializes from TOML string matching the format in the architecture doc +- P1d.AC1.2: SecurityScope deserializes correctly; Default gives permissive scope +- Inheritance: parent with `role = "coder"`, child with `role = "rust-coder"` → child role wins. Parent with `allowed_tools = ["file", "shell"]`, child with `allowed_tools = ["file"]` → child list wins (not merged). Parent system_prompt + child system_prompt → concatenated with separator. + +**Verification:** +Run: `cargo test profile_test` +Expected: All tests pass + +**Commit:** `feat(agent): AgentProfile type with SecurityScope and inheritance` + + + + + +### Task 4: Add token tracking fields to Response type + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/mod.rs` — add `input_tokens` and `output_tokens` to `Response` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/anthropic.rs` — populate token fields from API response +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/openai.rs` — populate token fields from API response +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/ollama.rs` — populate token fields (None if not available) +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/mock.rs` — return configurable token counts + +**Implementation:** + +Add to `Response` in `src/llm/mod.rs`: + +```rust +pub struct Response { + pub content: ResponseContent, + pub stop_reason: Option, + pub input_tokens: Option, + pub output_tokens: Option, +} +``` + +Update each provider to extract token usage from their API responses: +- Anthropic: `response.usage.input_tokens` and `response.usage.output_tokens` +- OpenAI: `response.usage.prompt_tokens` and `response.usage.completion_tokens` +- Ollama: `response.eval_count` for output, `response.prompt_eval_count` for input (if available) +- Mock: Add `pub fn set_token_counts(&self, input: usize, output: usize)` to configure returned values + +**Verification:** + +Run: `cargo test` +Expected: All existing tests pass (Response construction sites need updating with the new fields) + +**Commit:** `feat(llm): add token usage tracking to Response type` + + + + + + +### Task 5: Built-in profiles and profile resolution + +**Verifies:** P1d.AC3.2, P1d.AC3.3, P1d.AC3.4, P1d.AC3.5 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/builtin_profiles.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/profile.rs` — add `resolve_profile` function +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/profile_test.rs` (extend) + +**Implementation:** + +`src/agent/builtin_profiles.rs`: + +Define 5 functions, each returning an `AgentProfile`: +- `pub fn planner() -> AgentProfile` — read-only, graph+signal tools, system prompt for task breakdown +- `pub fn coder() -> AgentProfile` — write access (scoped), file+shell+graph+signal tools, system prompt for implementation +- `pub fn reviewer() -> AgentProfile` — read-only, file+shell+graph+signal tools, system prompt for code review +- `pub fn tester() -> AgentProfile` — write access (test dirs), file+shell+graph+signal tools, system prompt for test writing +- `pub fn researcher() -> AgentProfile` — read-only, file+shell+search+graph+signal tools, system prompt for information gathering + +Each profile's system_prompt follows the template from the architecture (lines 1507-1525). + +In `profile.rs`, add: + +```rust +pub fn resolve_profile( + name: &str, + project_path: Option<&Path>, +) -> Result { + // 1. Project-level: .rustagent/profiles/{name}.toml + if let Some(path) = project_path { + let profile_path = path.join(".rustagent/profiles").join(format!("{}.toml", name)); + if profile_path.exists() { + let content = std::fs::read_to_string(&profile_path)?; + let mut profile: AgentProfile = toml::from_str(&content)?; + if let Some(parent_name) = &profile.extends.clone() { + let parent = resolve_profile(parent_name, project_path)?; + profile.apply_inheritance(&parent); + } + return Ok(profile); + } + } + + // 2. User-level: ~/.config/rustagent/profiles/{name}.toml + if let Some(config_dir) = dirs::config_dir() { + let profile_path = config_dir.join("rustagent/profiles").join(format!("{}.toml", name)); + if profile_path.exists() { + let content = std::fs::read_to_string(&profile_path)?; + let mut profile: AgentProfile = toml::from_str(&content)?; + if let Some(parent_name) = &profile.extends.clone() { + let parent = resolve_profile(parent_name, project_path)?; + profile.apply_inheritance(&parent); + } + return Ok(profile); + } + } + + // 3. Built-in + match name { + "planner" => Ok(builtin_profiles::planner()), + "coder" => Ok(builtin_profiles::coder()), + "reviewer" => Ok(builtin_profiles::reviewer()), + "tester" => Ok(builtin_profiles::tester()), + "researcher" => Ok(builtin_profiles::researcher()), + _ => anyhow::bail!("Unknown profile: {}", name), + } +} +``` + +Add cycle detection: track resolved names in a `HashSet` and error if a name appears twice. + +**Testing:** + +Tests must verify: +- P1d.AC3.2: `resolve_profile("coder", None)` returns the built-in coder profile +- P1d.AC3.3: Create a tempdir with `.rustagent/profiles/custom.toml`. Resolve "custom" with that project path. Returns the custom profile. +- P1d.AC3.4: Create a project-level profile named "coder" that overrides the built-in. Resolve "coder" with that project path. Project-level wins. +- P1d.AC3.5: Create a custom profile with `extends = "coder"`. Resolve it. Verify inheritance applied correctly (system_prompt appended, list fields replaced, scalar fields overridden). + +**Verification:** +Run: `cargo test profile_test` +Expected: All tests pass + +**Commit:** `feat(agent): 5 built-in profiles and profile resolution chain` + + + + +### Task 6: AgentRuntime — agentic loop with error handling + +**Verifies:** P1d.AC4.1, P1d.AC4.2, P1d.AC4.3, P1d.AC4.4, P1d.AC4.5 + +**Files:** +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/runtime.rs` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/agent_runtime_test.rs` + +**Implementation:** + +`src/agent/runtime.rs`: + +```rust +pub struct AgentRuntime { + client: Arc, + tools: ToolRegistry, + profile: AgentProfile, + config: RuntimeConfig, +} + +pub struct RuntimeConfig { + pub max_turns: usize, // Default: 100 + pub max_consecutive_llm_failures: usize, // Default: 3 + pub max_consecutive_tool_failures: usize, // Default: 3 + pub token_budget: usize, // Default: 200_000 + pub token_budget_warning_pct: u8, // Default: 80 +} +``` + +`impl AgentRuntime`: +- `pub fn new(client, tools, profile, config) -> Self` +- `pub async fn run(&self, context: AgentContext) -> Result`: + +The loop is a refactored version of `RalphLoop::execute_task`: + +1. Build system message from context (using ContextBuilder — Task 6) +2. Loop for up to `max_turns`: + a. Call `client.chat(messages, tools)` + b. On LLM error: increment `consecutive_llm_failures`. If >= threshold, return `AgentOutcome::Blocked`. + c. On success: reset `consecutive_llm_failures` to 0. + d. Track token usage: `cumulative_tokens += response.input_tokens.unwrap_or(0) + response.output_tokens.unwrap_or(0)` (token fields added to `Response` in Task 4) + e. If cumulative_tokens >= warning threshold and not yet warned: inject system message "You are approaching your token budget. Wrap up your current work and signal completion." + f. If cumulative_tokens >= budget: return `AgentOutcome::TokenBudgetExhausted` + g. Handle tool calls: execute each via registry. On unknown tool or parse error, increment `consecutive_tool_failures` and send error back to LLM. On success, reset counter. + h. If `consecutive_tool_failures` >= threshold: return `AgentOutcome::Blocked` + i. Check for signal_completion tool call — return appropriate `AgentOutcome` +3. If loop exhausts max_turns: return `AgentOutcome::Completed` with summary "turn limit reached" + +Key difference from Ralph loop: error responses go back to the LLM as tool results (not panics), giving it a chance to self-correct. + +**Testing:** + +Tests use `MockLlmClient` from `src/llm/mock.rs`: + +- P1d.AC4.1: Queue a text response then a signal_completion tool call. Run. Returns `AgentOutcome::Completed`. +- P1d.AC4.2: Queue 3 consecutive responses with invalid tool calls (unknown tool name). Run. Returns `AgentOutcome::Blocked` with reason mentioning tool failures. +- P1d.AC4.3: Mock responses that consume tokens. Set budget to 1000 with warning at 80%. Verify "wrap up" message injected at 800 tokens. Set budget to 500. Verify `TokenBudgetExhausted` returned. +- P1d.AC4.4: Queue 3 consecutive errors from the mock client. Run. Returns `AgentOutcome::Blocked` with LLM failure reason. +- P1d.AC4.5: Set max_turns to 3. Queue responses that never signal completion. Run. Returns after 3 turns. + +**Verification:** +Run: `cargo test agent_runtime_test` +Expected: All tests pass + +**Commit:** `feat(agent): AgentRuntime with confusion counter, token budget, and failure thresholds` + + + + + + + +### Task 7: ContextBuilder and AGENTS.md resolution + +**Verifies:** P1d.AC5.1, P1d.AC5.2 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/Cargo.toml` — add `walkdir` and `glob` dependencies +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/context/mod.rs` +- Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/context/agents_md.rs` +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod context;` +- Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/context_test.rs` + +**Prerequisite:** Add `walkdir = "2"` and `glob = "0.3"` to `[dependencies]` in Cargo.toml. Run `cargo check` to verify. + +**Implementation:** + +`src/context/agents_md.rs`: + +```rust +pub struct AgentsMdSummary { + pub path: PathBuf, + pub headings: Vec, // Top-level headings extracted +} +``` + +`pub fn resolve_agents_md(project_root: &Path, file_scope: &[PathBuf]) -> Result>`: +1. Start at project root +2. For each file in scope, walk directory hierarchy from root toward the file +3. At each directory level, check for `AGENTS.md` (case-sensitive) +4. Extract top-level headings (lines starting with `# `) using simple string parsing — no full markdown parser needed for heading extraction +5. Return collected summaries, deduplicated, with closest-to-file ordering + +`src/context/mod.rs`: + +```rust +pub struct ContextBuilder; + +impl ContextBuilder { + pub fn build_system_prompt(ctx: &AgentContext) -> String +} +``` + +Also create a `ReadAgentsMdTool` implementing the existing `Tool` trait, so agents can call `read_agents_md(path)` during their agentic loop to get the full AGENTS.md content on demand (the system prompt only includes heading summaries). The tool takes a `path` parameter, reads the AGENTS.md file at that path, and returns its full contents. Register this tool in the v2 tool registry (`tools/factory.rs`). + +`build_system_prompt` assembles the compact structured format from the architecture (lines 1626-1654): + +``` +## Role +{profile.role} + +## Task +[TASK] {task.id} | {task.title} | priority={task.priority} +[CRITERIA] {acceptance_criteria, semicolon-separated} +... + +## Session Continuity +[HANDOFF] {handoff_notes} + +## Active Decisions +[DECISION] {id} | {title} | chosen: ... +... + +## Relevant Observations (use query_nodes(id) for full detail) +- {node_id}: {one-line summary} +... + +## Project Conventions (use read_agents_md(path) for full text) +- {path}: {heading1}, {heading2} ... + +## Rules +{profile.system_prompt rules section} +``` + +**Testing:** + +Tests must verify: +- P1d.AC5.1: Given an AgentContext with task, decisions, handoff notes — output contains all sections with correct formatting +- P1d.AC5.2: Create a tempdir with `AGENTS.md` at root and `src/AGENTS.md`. Resolve for scope `["src/auth/handler.rs"]`. Returns both files with `src/AGENTS.md` closer. + +**Verification:** +Run: `cargo test context_test` +Expected: All tests pass + +**Commit:** `feat(context): ContextBuilder with compact structured format and AGENTS.md resolution` + + + + +### Task 8: Wire up `rustagent run` for single-agent execution + +**Verifies:** P1d.AC6.1, P1d.AC6.2 + +**Files:** +- Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — replace v1 `Run` command with v2 version + +**Implementation:** + +Replace the existing `Run` command: + +```rust +/// Execute a goal with an agent +Run { + /// Goal description + goal: String, + /// Agent profile to use + #[arg(long, default_value = "coder")] + profile: String, + /// Maximum iterations + #[arg(long)] + max_iterations: Option, +}, +``` + +In the match arm for `Commands::Run`: +1. Resolve project (from `--project` flag or cwd) +2. Open database +3. Create goal node in the graph (using `SqliteGraphStore::create_node`) +4. Create a session (using `SessionStore::create_session`) +5. Resolve profile (using `resolve_profile`) +6. Build `AgentContext` from the goal, profile, and session +7. Create `AgentRuntime` with the profile's LLM config +8. Run the runtime +9. Handle `AgentOutcome` — update task nodes, end session, print result + +This is the first end-to-end integration: CLI -> database -> graph -> profile -> runtime -> LLM -> tools -> graph updates -> session end. + +For this phase (single-agent), there's no orchestrator — the CLI directly creates one agent and runs it. The orchestrator comes in Phase 2 (multi-agent). + +**Verification:** + +Run: `cargo build` +Expected: Compiles cleanly + +Manual verification (requires LLM API key): +``` +cargo run -- project add test-proj . +cargo run -- run --project test-proj "Create a hello world program" +``` +Expected: Agent creates tasks, attempts to execute them, records outcome. + +**Commit:** `feat(cli): v2 run command with single-agent execution` + + + diff --git a/docs/implementation-plans/2026-02-07-v2-phase1/test-requirements.md b/docs/implementation-plans/2026-02-07-v2-phase1/test-requirements.md new file mode 100644 index 0000000..ad0e821 --- /dev/null +++ b/docs/implementation-plans/2026-02-07-v2-phase1/test-requirements.md @@ -0,0 +1,330 @@ +# Test Requirements for V2 Phase 1 + +This document maps every acceptance criterion from Phase 1a through Phase 1d to specific automated tests or documented human verification steps. Each criterion is traced to the implementation plan task that produces it and the test file where verification lives. + +--- + +## Phase 1a: Database + Projects + +### P1a.AC1: Database initialization + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1a.AC1.1 | integration | `tests/db_test.rs` | `Database::open(path)` creates the SQLite file at the specified path. Uses `tempfile::TempDir` to verify file creation on disk. | +| P1a.AC1.2 | integration | `tests/db_test.rs` | After `Database::open`, query `PRAGMA journal_mode` returns `"wal"`, `PRAGMA foreign_keys` returns `1`, and `PRAGMA busy_timeout` returns `5000`. Uses `Database::open_in_memory()`. | +| P1a.AC1.3 | integration | `tests/db_test.rs` | After open, all expected tables exist in `sqlite_master`: `schema_version`, `projects`, `nodes`, `edges`, `sessions`, `nodes_fts` (virtual), `worker_conversations`. Verifies indexes and FTS sync triggers are also present. | +| P1a.AC1.4 | integration | `tests/db_test.rs` | After fresh init, `SELECT version FROM schema_version` returns `1`. | + +**Implementation task:** Phase 1a, Task 3 (Database module with initialization and migrations). + +### P1a.AC2: Schema versioning and migrations + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1a.AC2.1 | integration | `tests/db_test.rs` | `Database::open_in_memory()` creates full schema and sets version to 1. Verify all tables exist and version is correct. (Overlaps with P1a.AC1.3/AC1.4 but tested as a distinct scenario for fresh-database path.) | +| P1a.AC2.2 | integration | `tests/db_test.rs` | Open an already-initialized in-memory DB, then open it again (or call migration logic again). No error occurs and version remains 1. | +| P1a.AC2.3 | integration | `tests/db_test.rs` | Manually set `schema_version.version` to 999 via raw SQL, then trigger migration logic. Returns an error whose message contains `"newer version"`. | + +**Implementation task:** Phase 1a, Task 3. + +### P1a.AC3: Project registration + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1a.AC3.1 | integration | `tests/project_test.rs` | `ProjectStore::add("my-api", "/tmp/test")` returns a `Project` whose `id` starts with `"ra-"` followed by 4 hex characters, with correct `name` and `path` fields. | +| P1a.AC3.2 | integration | `tests/project_test.rs` | After adding 3 projects with names "alpha", "beta", "gamma", `ProjectStore::list()` returns all 3 ordered alphabetically by name. | +| P1a.AC3.3 | integration | `tests/project_test.rs` | After adding a project, `ProjectStore::get_by_name("my-api")` returns `Some(project)` with correct details. | +| P1a.AC3.4 | integration | `tests/project_test.rs` | After adding then removing a project, `ProjectStore::get_by_name` returns `None` and `remove` returns `true`. Removing a nonexistent project returns `false`. | +| P1a.AC3.5 | integration | `tests/project_test.rs` | Adding two projects with the same name returns an error (SQLite UNIQUE constraint violation). | +| P1a.AC3.6 | integration | `tests/project_test.rs` | After adding a project with path `/tmp/test-proj`, `ProjectStore::get_by_path("/tmp/test-proj")` returns the matching project. A non-matching path returns `None`. | + +**Implementation tasks:** Phase 1a, Task 4 (Project type and store) and Task 5 (list, show, remove, resolve from cwd). + +--- + +## Phase 1b: Graph Model + Node Lifecycle + +### P1b.AC1: Graph node types and data model + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1b.AC1.1 | unit | `tests/graph_types_test.rs` | `GraphNode` struct can be constructed with all fields from the architecture (id, project_id, node_type, title, description, status, priority, assigned_to, created_by, labels, created_at, started_at, completed_at, blocked_reason, metadata). Roundtrips through JSON serialization. | +| P1b.AC1.2 | unit | `tests/graph_types_test.rs` | All 7 `NodeType` variants (Goal, Task, Decision, Option, Outcome, Observation, Revisit) exist and roundtrip through `Display`/`FromStr` (e.g., `NodeType::Goal.to_string()` -> `"goal"` -> `NodeType::from_str("goal")` -> `NodeType::Goal`). | +| P1b.AC1.3 | unit | `tests/graph_types_test.rs` | `NodeStatus` enum has all variants. `validate_status(NodeType::Task, NodeStatus::Ready)` returns Ok. `validate_status(NodeType::Goal, NodeStatus::Ready)` returns Err. Tests cover every node type's valid status set per the architecture spec: Goal (Pending, Active, Completed, Cancelled), Task (Pending, Ready, Claimed, InProgress, Review, Completed, Blocked, Failed, Cancelled), Decision (Pending, Active, Decided, Superseded), Option (Pending, Active, Chosen, Rejected, Abandoned), Outcome (Active, Completed), Observation (Active), Revisit (Active, Completed). | +| P1b.AC1.4 | unit | `tests/graph_types_test.rs` | `GraphEdge` struct can be constructed with all fields (id, edge_type, from_node, to_node, label, created_at). Roundtrips through JSON serialization. | +| P1b.AC1.5 | unit | `tests/graph_types_test.rs` | All 7 `EdgeType` variants (Contains, DependsOn, LeadsTo, Chosen, Rejected, Supersedes, Informs) roundtrip through `Display`/`FromStr`. | + +**Implementation task:** Phase 1b, Task 1 (Graph types). + +### P1b.AC2: Hierarchical ID generation + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1b.AC2.1 | unit | `tests/graph_types_test.rs` | `generate_goal_id()` returns a string matching `^ra-[0-9a-f]{4}$`. Called multiple times, produces unique IDs (statistical check). | +| P1b.AC2.2 | unit | `tests/graph_types_test.rs` | `generate_child_id("ra-a3f8", 1)` returns `"ra-a3f8.1"`. `generate_child_id("ra-a3f8.1", 3)` returns `"ra-a3f8.1.3"`. Nesting is unbounded. | +| P1b.AC2.3 | unit | `tests/graph_types_test.rs` | Generated IDs contain only valid primary key characters (`[a-z0-9\-\.]`). `parent_id("ra-a3f8.1.3")` returns `Some("ra-a3f8.1")`; `parent_id("ra-a3f8")` returns `None`. | +| P1b.AC2.4 | unit | `tests/graph_types_test.rs` | `generate_edge_id()` returns a string matching `^e-[0-9a-f]{8}$`. | + +**Implementation task:** Phase 1b, Task 2 (ID generation helpers). + +### P1b.AC3: GraphStore CRUD + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1b.AC3.1 | integration | `tests/graph_store_test.rs` | Create a goal node via `SqliteGraphStore::create_node`. Retrieve it via `get_node(id)`. All fields match. | +| P1b.AC3.2 | integration | `tests/graph_store_test.rs` | `get_node("nonexistent-id")` returns `None` (not an error). | +| P1b.AC3.3 | integration | `tests/graph_store_test.rs` | Create a node with status Pending. Call `update_node` to set status to Active. `get_node` confirms status is Active. Metadata updates also verified. | +| P1b.AC3.4 | integration | `tests/graph_store_test.rs` | Create two nodes, `add_edge` with a Contains edge between them. `get_edges(from_node, Outgoing)` returns the edge paired with the target node. `get_edges(to_node, Incoming)` returns the edge paired with the source node. | +| P1b.AC3.5 | integration | `tests/graph_store_test.rs` | Create a goal + 2 child tasks with Contains edges. `get_children(goal_id)` returns both children with their edge types. | +| P1b.AC3.6 | integration | `tests/graph_store_test.rs` | Create goal -> task -> subtask chain via Contains edges. `get_subtree(goal_id)` returns all 3 nodes (recursive CTE walk). Verify subtask is included despite being a grandchild. | + +**Additional coverage (not mapped to a numbered AC but tested as part of AC3):** + +| Extra | Type | Test File | Description | +|-------|------|-----------|-------------| +| get_active_decisions | integration | `tests/graph_store_test.rs` | Create 2 Decision nodes under a project -- one Active, one Superseded. `get_active_decisions(project_id)` returns only the Active one. | +| get_full_graph | integration | `tests/graph_store_test.rs` | Create a goal with tasks and edges. `get_full_graph(goal_id)` returns a `WorkGraph` containing all nodes and all edges involving those nodes. | + +**Implementation task:** Phase 1b, Task 4 (SqliteGraphStore CRUD). + +### P1b.AC4: Dependency resolution and task surfacing + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1b.AC4.1 | integration | `tests/graph_dependency_test.rs` | Create task A (Pending, no deps) and task B (Pending, DependsOn A). Update A to Completed. Verify B's status is automatically updated to Ready by the status transition hook. | +| P1b.AC4.2 | integration | `tests/graph_dependency_test.rs` | Create 3 tasks under a goal: one manually set to Ready, one Pending with unmet dependency, one Completed. `get_ready_tasks(goal_id)` returns exactly the Ready one. | +| P1b.AC4.3 | integration | `tests/graph_dependency_test.rs` | Create 2 Ready tasks: one High priority blocking 3 downstream tasks, one Critical priority blocking 0. `get_next_task(goal_id)` returns the Critical one (priority wins over downstream unblock count). Verify tie-breaking: 2 tasks of same priority -- the one with more downstream dependents wins. | + +**Implementation task:** Phase 1b, Task 5 (Dependency resolution and task surfacing). + +### P1b.AC5: Atomic task claiming + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1b.AC5.1 | integration | `tests/graph_claim_search_test.rs` | Create a Ready task. `claim_task(id, "agent-1")` returns `true`. Node now has status Claimed and `assigned_to = "agent-1"`. | +| P1b.AC5.2 | integration | `tests/graph_claim_search_test.rs` | Create a Ready task, claim it once (returns `true`), claim it again with a different agent (returns `false` -- already claimed). | +| P1b.AC5.1 + P1b.AC5.2 (concurrency) | integration | `tests/graph_concurrency_test.rs` | Spawn 10 tokio tasks all calling `claim_task` for the same Ready task simultaneously. Exactly 1 succeeds (`true`), the other 9 get `false`. Task ends up Claimed with a single `assigned_to`. | + +**Implementation tasks:** Phase 1b, Task 6 (Atomic task claiming) and Task 9 (Concurrency test). + +### P1b.AC6: Full-text search + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1b.AC6.1 | integration | `tests/graph_claim_search_test.rs` | Create nodes with various titles and descriptions (e.g., "authentication handler", "database schema"). `search_nodes("authentication")` returns nodes containing that term in title or description. Nodes without the term are excluded. | +| P1b.AC6.2 | integration | `tests/graph_claim_search_test.rs` | Create nodes in 2 different projects (project A and project B). Search with `project_id = A` returns only project A's nodes. Create nodes of different types (Task and Observation). Search with `node_type = Task` returns only Task nodes. Combined filter (project + type) works correctly. | + +**Implementation task:** Phase 1b, Task 6 (FTS5 search). + +### P1b.AC7: Graph tools for agents + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1b.AC7.1 | integration | `tests/graph_tools_test.rs` | **Low-level tools**: (1) `CreateNodeTool::execute()` with goal params creates a node retrievable from the store. (2) `UpdateNodeTool::execute()` changes a node's status. (3) `AddEdgeTool::execute()` creates an edge between two nodes. (4) `QueryNodesTool::execute()` returns JSON array of matching nodes filtered by type/status. (5) `SearchNodesTool::execute()` returns FTS5 search results. Each tool is tested via its `execute()` method with JSON params passing through the `Tool` trait interface. | +| P1b.AC7.2 | integration | `tests/graph_tools_test.rs` | **High-level tools**: (1) `LogDecisionTool::execute()` with 2 options creates 1 Decision node + 2 Option nodes + 2 LeadsTo edges. (2) `ChooseOptionTool::execute()` adds Chosen edge to selected option, Rejected edges to others, updates Decision status to Decided. (3) `RecordOutcomeTool::execute()` creates Outcome node + LeadsTo edge from parent. (4) `RecordObservationTool::execute()` creates Observation node + Informs edge to related node. (5) `RevisitTool::execute()` creates Revisit node + LeadsTo edge from failed outcome, optionally creates new Decision node. | + +**Implementation task:** Phase 1b, Task 7 (Graph tools for agents). + +--- + +## Phase 1c: Sessions + Export + Interchange + +### P1c.AC1: Session management + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1c.AC1.1 | integration | `tests/session_test.rs` | `SessionStore::create_session(project_id, goal_id)` returns a `Session` with a non-empty ID, correct goal_id, non-null `started_at`, and `ended_at = None`. | +| P1c.AC1.2 | integration | `tests/session_test.rs` | Set up a goal with tasks (some Completed, some Pending) and a Decided Decision. Call `end_session(session_id, graph_store)`. Session now has non-null `handoff_notes` and `ended_at`. | +| P1c.AC1.3 | integration | `tests/session_test.rs` | Verify the handoff notes string from P1c.AC1.2 contains all 4 sections: "## Done" (lists completed tasks), "## Remaining" (lists pending/in-progress tasks), "## Blocked" (lists blocked tasks or shows none), "## Decisions Made" (lists decided decisions with chosen option). Exact content validated against the test data setup. | +| P1c.AC1.4 | integration | `tests/session_test.rs` | Create 2 sessions for the same goal (create first, end it, create second). `get_latest_session(goal_id)` returns the second session (most recent by `started_at`). | + +**Implementation task:** Phase 1c, Task 1 (Session management and handoff notes). + +### P1c.AC2: ADR export + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1c.AC2.1 | integration | `tests/adr_export_test.rs` | Create 2 Decision nodes in a project (each with Options, one Decided). Call `export_adrs(project_id, tempdir)`. Two files are created at `tempdir/001-*.md` and `tempdir/002-*.md`. Files are numbered sequentially by creation date. | +| P1c.AC2.2 | integration | `tests/adr_export_test.rs` | Read the exported markdown. Verify it contains: (1) `# ADR-001:` title header, (2) `## Status:` section, (3) `## Context:` from Decision description, (4) `## Options Considered:` with CHOSEN/REJECTED labels and pros/cons from Option metadata, (5) `## Outcome:` section (if Outcome node exists), (6) `## Related Tasks:` listing associated task IDs. | + +**Implementation task:** Phase 1c, Task 2 (ADR export). + +### P1c.AC3: TOML graph interchange + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1c.AC3.1 | integration | `tests/interchange_test.rs` | Create a goal with tasks, decisions, options, and edges. Call `export_goal(goal_id, project_name)`. Parse the returned TOML string. Verify `[meta]` section has version, goal_id, project, exported_at, content_hash. Verify `[nodes.*]` section contains all nodes with correct fields. Verify `[edges.*]` section contains all edges. | +| P1c.AC3.2 | integration | `tests/interchange_test.rs` | Export the same goal twice without modifications. Assert the two TOML strings are byte-identical (`assert_eq!`). This validates deterministic key ordering (BTreeMap), deterministic timestamps (no re-generation), and omitted null fields. | +| P1c.AC3.3 | integration | `tests/interchange_test.rs` | Export a goal. Modify a node's title in the TOML string (string manipulation). Import with `ImportStrategy::Theirs`. Verify the DB now has the modified title. Also test `Ours` strategy: modify a node, import with Ours, verify DB retains the original value. | +| P1c.AC3.4 | integration | `tests/interchange_test.rs` | Export goal from DB A. Import into a fresh DB B. Export from DB B. Assert both TOML strings are byte-identical (round-trip property). | +| P1c.AC3.5 | integration | `tests/interchange_test.rs` | Create a goal in DB. Export to TOML. Add a new node and change an existing node's title in the TOML string. Call `diff_goal(toml_content)`. Verify `DiffResult.added_nodes` has 1 entry, `changed_nodes` has 1 entry with the changed field listed, and `unchanged_nodes` count matches expectations. | +| P1c.AC3.6 | integration | `tests/interchange_test.rs` | Craft a TOML string containing an edge whose `to_node` references a nonexistent node ID (e.g., a cross-goal reference). Import it. Verify the edge appears in `ImportResult.skipped_edges` with a descriptive message, and the edge is NOT created in the DB. | + +**Implementation tasks:** Phase 1c, Task 3 (TOML export), Task 4 (TOML import), Task 5 (TOML diff). + +### P1c.AC4: Node decay + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1c.AC4.1 | unit | `tests/decay_test.rs` | Create a `GraphNode` with `completed_at` 2 days ago. Call `decay_node(node, now, default_config)`. Result has `DecayDetail::Full` containing description and metadata. | +| P1c.AC4.2 | unit | `tests/decay_test.rs` | Create a `GraphNode` with `completed_at` 15 days ago. Call `decay_node(node, now, default_config)`. Result has `DecayDetail::Summary` with title and status but no full description. | +| P1c.AC4.3 | unit | `tests/decay_test.rs` | Create a `GraphNode` with `completed_at` 45 days ago. Call `decay_node(node, now, default_config)`. Result has `DecayDetail::Minimal` with only title and status. | +| P1c.AC4.4 | unit | `tests/decay_test.rs` | Custom config: `DecayConfig { recent_days: 3, older_days: 10 }`. Node completed 5 days ago. `decay_node` returns `Summary` (not `Full`, since 5 > 3). Node completed 2 days ago returns `Full` (2 < 3). Node completed 15 days ago returns `Minimal` (15 > 10). | + +**Implementation task:** Phase 1c, Task 6 (Node decay for context injection). + +### P1c.AC5: CLI commands + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1c.AC5.1 | human | N/A | See Human Verification table below. | +| P1c.AC5.2 | human | N/A | See Human Verification table below. | +| P1c.AC5.3 | human | N/A | See Human Verification table below. | +| P1c.AC5.4 | human | N/A | See Human Verification table below. | + +**Implementation task:** Phase 1c, Task 7 (Wire up CLI commands). + +--- + +## Phase 1d: Agent Runtime + Single-Agent Execution + +### P1d.AC1: Cherry-pick and adapt existing modules + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1d.AC1.1 | unit | N/A (compile check) | `cargo check` succeeds after removing TUI imports from `ralph/mod.rs`. No references to `ratatui`, `crossterm`, or `tui` remain. Verified by `cargo check` during Task 1 and by the full `cargo test` run. | +| P1d.AC1.2 | unit | `tests/profile_test.rs` | `SecurityScope` type compiles, deserializes from TOML, and `Default` returns permissive scope (verified alongside P1d.AC3.1 tests). | +| P1d.AC1.3 | integration | `tests/graph_tools_test.rs` | The v2 tool registry factory (`create_v2_registry`) includes graph tools alongside existing file/shell/signal tools. Verified indirectly by the graph tools tests which construct tools through the factory. | + +**Implementation tasks:** Phase 1d, Task 1 (Clean up cherry-picked modules), Task 3 (SecurityScope). + +**Rationale for P1d.AC1.1:** This is a compile-time property. The test suite implicitly verifies it because `cargo test` runs `cargo check` first. No runtime test is needed -- if the TUI references remain, nothing compiles. + +### P1d.AC2: Agent trait and types + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1d.AC2.1 | unit | `tests/agent_types_test.rs` | A mock struct implementing the `Agent` trait compiles and can return values from `id()`, `profile()`, `run()`, and `cancel()`. This verifies the trait's method signatures. | +| P1d.AC2.2 | unit | `tests/agent_types_test.rs` | `AgentContext` struct can be constructed with all required fields: `work_package_tasks` (Vec), `relevant_decisions` (Vec), `handoff_notes` (Option), `agents_md_summaries` (Vec<(String, String)>), `profile` (AgentProfile), `project_path` (PathBuf), `graph_store` (Arc). | +| P1d.AC2.3 | unit | `tests/agent_types_test.rs` | All 4 `AgentOutcome` variants can be constructed and pattern-matched: `Completed { summary }`, `Blocked { reason }`, `Failed { error }`, `TokenBudgetExhausted { summary, tokens_used }`. | + +**Implementation task:** Phase 1d, Task 2 (Agent trait and AgentOutcome). + +### P1d.AC3: Agent profiles + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1d.AC3.1 | unit | `tests/profile_test.rs` | `AgentProfile` deserializes from a TOML string containing all fields (name, extends, role, system_prompt, allowed_tools, security, llm, turn_limit, token_budget). All values round-trip correctly. | +| P1d.AC3.2 | unit | `tests/profile_test.rs` | `resolve_profile("coder", None)` returns the built-in coder profile. Same for all 5 built-in profiles: planner, coder, reviewer, tester, researcher. Each has a non-empty system_prompt and role. | +| P1d.AC3.3 | integration | `tests/profile_test.rs` | Create a `tempfile::TempDir` with `.rustagent/profiles/custom.toml` containing a valid profile TOML. `resolve_profile("custom", Some(tempdir_path))` returns the custom profile with correct fields. | +| P1d.AC3.4 | integration | `tests/profile_test.rs` | Create a project-level profile file named `coder.toml` that overrides the built-in coder. `resolve_profile("coder", Some(project_path))` returns the project-level profile (not the built-in). Verify by checking a distinctive field value. | +| P1d.AC3.5 | integration | `tests/profile_test.rs` | Create a custom profile with `extends = "coder"`. Resolve it. Verify: (1) `system_prompt` is parent's prompt + separator + child's prompt (appended). (2) `allowed_tools` is the child's list only (replaced, not merged). (3) Scalar fields like `role` take the child's value. (4) Optional fields like `turn_limit` fall through to parent if child is `None`. | + +**Implementation tasks:** Phase 1d, Task 3 (AgentProfile + SecurityScope), Task 5 (Built-in profiles and resolution). + +### P1d.AC4: AgentRuntime (agentic loop) + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1d.AC4.1 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient`: queue a text response followed by a `signal_completion` tool call. Run the runtime. Returns `AgentOutcome::Completed` with a summary. Verify the tool was executed through the registry. | +| P1d.AC4.2 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient`: queue 3 consecutive responses that each request an unknown/invalid tool name. Run with `max_consecutive_tool_failures = 3`. Returns `AgentOutcome::Blocked` with reason mentioning consecutive tool failures. | +| P1d.AC4.3 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient` with configurable token counts: (1) Set budget to 1000, warning at 80% (800). Queue responses that cumulatively reach 800+ tokens. Verify a "wrap up" system message is injected into the conversation. (2) Set budget to 500. Queue responses exceeding 500 tokens. Returns `AgentOutcome::TokenBudgetExhausted` with tokens_used >= 500. | +| P1d.AC4.4 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient`: configure the mock to return errors on `chat()`. Set `max_consecutive_llm_failures = 3`. Run. After 3 consecutive LLM errors, returns `AgentOutcome::Blocked` with reason mentioning LLM failures. | +| P1d.AC4.5 | integration | `tests/agent_runtime_test.rs` | Set `max_turns = 3`. Queue responses that never call `signal_completion` (e.g., just text or non-terminating tool calls). Run. Returns after exactly 3 turns with a completion summary mentioning "turn limit". | + +**Implementation task:** Phase 1d, Task 6 (AgentRuntime with error handling). + +### P1d.AC5: Context assembly + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1d.AC5.1 | unit | `tests/context_test.rs` | Construct an `AgentContext` with: 2 work package tasks, 1 relevant decision, handoff notes text, and 2 AGENTS.md summaries. Call `ContextBuilder::build_system_prompt(ctx)`. Verify the output string contains all sections: `## Role`, `## Task` (with `[TASK]` and `[CRITERIA]` markers for each task), `## Session Continuity` (with `[HANDOFF]`), `## Active Decisions` (with `[DECISION]`), `## Project Conventions` (with AGENTS.md paths and headings), `## Rules`. | +| P1d.AC5.2 | unit | `tests/context_test.rs` | Create a `tempfile::TempDir` representing a project with `AGENTS.md` at root and `src/AGENTS.md` nested inside. Call `resolve_agents_md(project_root, &["src/auth/handler.rs"])`. Returns 2 summaries. The `src/AGENTS.md` summary appears first (closest-to-file). Both summaries contain extracted top-level headings. | + +**Implementation task:** Phase 1d, Task 7 (ContextBuilder and AGENTS.md resolution). + +### P1d.AC6: Single-agent CLI + +| AC | Type | Test File | Description | +|----|------|-----------|-------------| +| P1d.AC6.1 | human | N/A | See Human Verification table below. | +| P1d.AC6.2 | human | N/A | See Human Verification table below. | + +**Implementation task:** Phase 1d, Task 8 (Wire up `rustagent run`). + +--- + +## Human Verification Required + +The following acceptance criteria cannot be fully automated because they depend on CLI output formatting, user-facing presentation, or live LLM interaction that requires API keys and subjective evaluation. + +| AC | Phase | Reason | Verification Approach | +|----|-------|--------|----------------------| +| P1c.AC5.1 | 1c | CLI output formatting for `rustagent sessions` is presentation-level. The underlying `SessionStore::list_sessions` is tested in `tests/session_test.rs`; the CLI wiring is a thin print layer. | Run `cargo run -- sessions --goal ` after creating test data. Verify the output lists sessions with IDs, start/end times, and goal references. Verify `--help` shows the subcommands. | +| P1c.AC5.2 | 1c | CLI output formatting for `rustagent sessions latest` is presentation-level. The underlying `SessionStore::get_latest_session` is tested in `tests/session_test.rs`. | Run `cargo run -- sessions latest --goal ` after ending a session. Verify the output displays the handoff notes with all 4 sections (Done, Remaining, Blocked, Decisions Made). | +| P1c.AC5.3 | 1c | CLI wiring for `rustagent decisions export` involves file writes to a user-specified directory. The underlying `export_adrs` function is tested in `tests/adr_export_test.rs`. | Run `cargo run -- decisions export --project --output /tmp/adrs`. Verify ADR files appear in the output directory. Inspect file contents. | +| P1c.AC5.4 | 1c | CLI wiring for `rustagent graph export/import/diff`. The underlying interchange functions are tested in `tests/interchange_test.rs`. | Run `cargo run -- graph export --goal ` and verify TOML output. Run `cargo run -- graph import ` and verify import summary. Run `cargo run -- graph diff ` and verify diff output. Verify `--help` for each subcommand. | +| P1d.AC1.1 | 1d | Compile-time property. Not a runtime test -- verified implicitly by `cargo check` / `cargo test` succeeding. | Run `cargo check`. If it compiles, the criterion is met. Search for `ratatui`, `crossterm`, `tui` in `src/` to confirm removal. | +| P1d.AC6.1 | 1d | End-to-end `rustagent run` requires a live LLM API key (Anthropic/OpenAI) to execute the agent loop. The agentic loop itself is tested with `MockLlmClient` in `tests/agent_runtime_test.rs`, but the CLI integration layer (DB open, goal creation, session management, profile resolution, outcome handling) is a thin orchestration layer that is impractical to mock in an automated test without significant test infrastructure. | **Manual steps**: (1) Set `ANTHROPIC_API_KEY` env var. (2) Run `cargo run -- project add test-proj .` (3) Run `cargo run -- run --project test-proj "Create a hello world program"`. (4) Verify: a goal node is created in the DB, a session is created, the agent executes tool calls (visible in logs at `RUST_LOG=rustagent=debug`), and an outcome is recorded. (5) Run `cargo run -- sessions latest --goal ` to verify handoff notes were generated. | +| P1d.AC6.2 | 1d | Profile selection via `--profile` flag requires end-to-end CLI execution. | **Manual steps**: (1) Run `cargo run -- run --project test-proj --profile reviewer "Review the codebase"`. (2) Verify the agent uses the reviewer profile's system prompt (visible in debug logs). (3) Run with `--profile nonexistent` and verify a clear error message. | + +--- + +## Test File Summary + +| Test File | Phase | Acceptance Criteria Covered | +|-----------|-------|-----------------------------| +| `tests/db_test.rs` | 1a | P1a.AC1.1, P1a.AC1.2, P1a.AC1.3, P1a.AC1.4, P1a.AC2.1, P1a.AC2.2, P1a.AC2.3 | +| `tests/project_test.rs` | 1a | P1a.AC3.1, P1a.AC3.2, P1a.AC3.3, P1a.AC3.4, P1a.AC3.5, P1a.AC3.6 | +| `tests/graph_types_test.rs` | 1b | P1b.AC1.1, P1b.AC1.2, P1b.AC1.3, P1b.AC1.4, P1b.AC1.5, P1b.AC2.1, P1b.AC2.2, P1b.AC2.3, P1b.AC2.4 | +| `tests/graph_store_test.rs` | 1b | P1b.AC3.1, P1b.AC3.2, P1b.AC3.3, P1b.AC3.4, P1b.AC3.5, P1b.AC3.6 | +| `tests/graph_dependency_test.rs` | 1b | P1b.AC4.1, P1b.AC4.2, P1b.AC4.3 | +| `tests/graph_claim_search_test.rs` | 1b | P1b.AC5.1, P1b.AC5.2, P1b.AC6.1, P1b.AC6.2 | +| `tests/graph_concurrency_test.rs` | 1b | P1b.AC5.1, P1b.AC5.2 (concurrency aspect) | +| `tests/graph_tools_test.rs` | 1b | P1b.AC7.1, P1b.AC7.2 | +| `tests/session_test.rs` | 1c | P1c.AC1.1, P1c.AC1.2, P1c.AC1.3, P1c.AC1.4 | +| `tests/adr_export_test.rs` | 1c | P1c.AC2.1, P1c.AC2.2 | +| `tests/interchange_test.rs` | 1c | P1c.AC3.1, P1c.AC3.2, P1c.AC3.3, P1c.AC3.4, P1c.AC3.5, P1c.AC3.6 | +| `tests/decay_test.rs` | 1c | P1c.AC4.1, P1c.AC4.2, P1c.AC4.3, P1c.AC4.4 | +| `tests/agent_types_test.rs` | 1d | P1d.AC2.1, P1d.AC2.2, P1d.AC2.3 | +| `tests/profile_test.rs` | 1d | P1d.AC1.2, P1d.AC3.1, P1d.AC3.2, P1d.AC3.3, P1d.AC3.4, P1d.AC3.5 | +| `tests/agent_runtime_test.rs` | 1d | P1d.AC4.1, P1d.AC4.2, P1d.AC4.3, P1d.AC4.4, P1d.AC4.5 | +| `tests/context_test.rs` | 1d | P1d.AC5.1, P1d.AC5.2 | + +--- + +## Coverage Audit + +**Total acceptance criteria:** 53 + +**Automated test coverage:** 44 criteria (83%) + +**Human verification only:** 7 criteria (13%) -- P1c.AC5.1, P1c.AC5.2, P1c.AC5.3, P1c.AC5.4, P1d.AC6.1, P1d.AC6.2, P1d.AC1.1 + +**Compile-time verification:** 1 criterion (2%) -- P1d.AC1.1 (verified implicitly by `cargo check`) + +**Hybrid (automated + human):** 1 criterion -- P1d.AC1.3 (verified indirectly through graph tools tests, but full registry integration is a compile-time property) + +All 53 acceptance criteria are mapped to either an automated test or a documented human verification procedure. No criteria are left unaddressed. + +--- + +## Implementation Notes + +### Test Infrastructure Patterns + +All integration tests follow these patterns established in the implementation plans: + +1. **In-memory database:** `Database::open_in_memory()` for all tests except P1a.AC1.1 (which specifically tests file creation and uses `tempfile::TempDir`). +2. **Async runtime:** All integration tests use `#[tokio::test]`. +3. **Test helpers:** Each test file should define helper functions to create test nodes/edges with sensible defaults, reducing boilerplate. +4. **MockLlmClient:** Phase 1d runtime tests use the existing `src/llm/mock.rs` with queued responses and configurable token counts (token count support added in Phase 1d Task 4). + +### Architecture Deviations Reflected in Tests + +The following implementation deviations from the architecture are reflected in the test design: + +- **`update_node` partial-update signature (Phase 1b):** Tests pass `Option` fields rather than full `GraphNode` structs, matching the implementation's partial-update approach that avoids read-modify-write races. +- **Project `config_overrides` and `metadata` as JSON strings (Phase 1a):** Tests treat these as `String` / `Option` rather than typed structs, matching the DB-first representation. +- **Session TOML export deferred (Phase 1c):** No tests for `.rustagent/sessions/*.toml` file generation. Session tests cover DB records and handoff notes only. +- **`cancel()` as no-op (Phase 1d):** Tests verify `cancel()` compiles and can be called but do not test actual cancellation behavior (deferred to Phase 2 with CancellationToken). diff --git a/docs/plans/v2-architecture-review.md b/docs/plans/v2-architecture-review.md new file mode 100644 index 0000000..8d43606 --- /dev/null +++ b/docs/plans/v2-architecture-review.md @@ -0,0 +1,288 @@ +# V2 Architecture Review: Gap Analysis + +**Date:** 2026-02-06 +**Reviewer:** Claude (technical product owner perspective) +**Document under review:** `docs/plans/v2-architecture.md` + +--- + +## Critical Issues (Will Cause System Failures) + +### 1. Hash-Based ID Scheme Is Mathematically Broken + +The plan specifies 4 hex characters (16 bits, 65,536 possibilities) truncated from UUID v4 and claims it's "collision-safe enough for <10 concurrent agents." **This is provably wrong.** + +| Node Count | P(collision) | Scenario | +|-----------|-------------|----------| +| 50 | 1.85% | Tiny single-agent project | +| 100 | 7.27% | Small project | +| 300 | ~50% | Medium project, 1 agent | +| 600 | ~94% | Medium project, 3 agents | +| 1,500 | ~100% | 10 agents (the stated use case) | + +A medium project generates 300-600 nodes (goals + tasks + subtasks + decisions + options + outcomes + observations + revisits). The birthday bound is ~301 IDs for a 50% collision probability. With 10 concurrent agents the situation is dramatically worse, not better, since each agent generates IDs independently. + +**Impact:** Collisions hit the PRIMARY KEY constraint. Either INSERTs fail silently (agent loses work), or with upsert semantics, one node overwrites another. The work graph — the central coordination mechanism — becomes corrupt. + +**Fix:** Use 8 hex characters (32 bits). At 10,000 nodes the collision probability is 1.16%. This matches the convention git uses for short hashes and is still human-typeable (`ra-a3f8b2c1.1.3`). Alternatively, clarify that the 4-char hash only needs sibling-uniqueness (within one parent) and use the full hierarchical path as the PK. + +### 2. SQLite Write Contention Is Not Addressed + +The plan says "WAL mode for concurrent reads" but **WAL does not enable concurrent writes**. SQLite remains single-writer even in WAL mode. With 4+ workers writing simultaneously (task claims, status updates, node creation, edge creation), you'll hit `SQLITE_BUSY` errors. + +Specific dangers: +- **Transaction upgrade deadlocks**: If a transaction starts as a reader then tries to write, SQLite returns `SQLITE_BUSY` *immediately* without respecting `busy_timeout` +- **"Atomic claim"** (`claim_task`) is described but no implementation strategy is given. A naive read-then-write is NOT atomic without `BEGIN IMMEDIATE` +- **tokio-rusqlite does not serialize writes** — the application must handle this + +**Missing from the plan:** +- Write serialization strategy (single connection with mutex? Channel-based write queue?) +- Transaction isolation level choices (`IMMEDIATE` vs `DEFERRED`) +- `busy_timeout` configuration +- Retry logic for `SQLITE_BUSY` +- Checkpoint tuning (`wal_autocheckpoint`) + +**Fix:** Add a "Database Concurrency Strategy" section specifying: single `tokio-rusqlite` connection, all write transactions use `BEGIN IMMEDIATE`, `busy_timeout` of 5000ms, and application-level write queue for high-contention operations. + +### 3. No Error Handling Strategy for LLM Failures Mid-Task + +The plan details worker lifecycle states but never addresses what happens when: +- An LLM API call fails mid-task execution (network error, rate limit, context window exceeded) +- A worker's LLM returns malformed tool calls +- A worker hallucinates and generates invalid graph operations +- A worker exceeds its token budget + +The current codebase has `llm/retry.rs` with rate-limit handling, but there's no specification for how the **orchestrator** should handle persistent LLM failures vs transient ones. `max_retries_per_task: 2` is mentioned but retry semantics aren't defined — does it re-spawn a fresh worker? Reuse the same context? Reset the task fully? + +--- + +## Significant Gaps (Incomplete Specifications) + +### 4. Agent Profiles: Referenced But Not Specified + +The plan mentions "Agent profiles in TOML config" and `builtin_profiles.rs` with planner/coder/reviewer/tester/researcher, but never specifies: +- What fields an `AgentProfile` contains beyond what's in the struct +- What system prompts each built-in profile uses +- How profiles control which tools an agent can access +- How `SecurityScope` maps to profiles +- The TOML schema for custom profiles +- Whether profiles can inherit from/extend built-in profiles + +This is one of the most important design surfaces — it determines how agents *behave* — and it's entirely hand-waved. + +### 5. Autonomy Levels: Named But Not Defined + +`config/autonomy.rs` is listed with types `AutonomyLevel` and `ApprovalGate`. The orchestrator mentions "configurable approval gates." But nowhere does the plan define: +- What autonomy levels exist +- What actions each level permits/restricts +- What approval gates are available +- How gates interact with the orchestrator state machine +- Whether autonomy is per-project, per-goal, or per-agent +- How approval requests are surfaced to users (CLI? Web UI? Both?) + +### 6. ContextBuilder: The Glue That's Missing + +`context/mod.rs` is supposed to combine AGENTS.md, memories, task details, and decisions into an `AgentContext` for each worker. But there's no specification for: +- How context is prioritized when it exceeds the LLM's context window +- Token budgeting strategy (how much of the window goes to system prompt vs memories vs task details vs conversation history) +- How AGENTS.md sections are matched to a worker's file scope +- Whether context is static (built once at spawn) or dynamic (refreshed during execution) +- How the memory decay thresholds work concretely (age-based? access-based? what are "recent", "older", "ancient"?) + +### 7. AGENTS.md Format: Undefined + +The plan mentions "AGENTS.md parser + resolver" and "closest-to-file resolution" but never specifies: +- The expected format of AGENTS.md files +- What sections/headings are recognized +- How inheritance works when multiple AGENTS.md files exist in a directory hierarchy +- Whether this follows any existing convention (Cursor rules? Claude's own CLAUDE.md?) or is a new format + +### 8. Memory System: Embedding Dimension Mismatch + +The schema hardcodes `FLOAT[1536]` for embeddings (OpenAI's `text-embedding-ada-002` dimension). But: +- The plan supports Ollama embeddings, which use different dimensions (e.g., `nomic-embed-text` = 768, `mxbai-embed-large` = 1024) +- There's no strategy for mixed-dimension embeddings +- Switching providers would require re-embedding all existing memories or supporting multiple virtual tables +- No embedding model is specified in the config schema + +### 9. Cross-Goal Edge Import: Warning-Not-Error Is Dangerous + +The plan says cross-goal edges produce "warnings, not errors" when the referenced goal isn't imported yet. But this means: +- The graph can be in an inconsistent state after import +- Dangling edge references violate referential integrity +- There's no mechanism to later resolve these warnings +- A user could import files in any order and get a silently broken graph + +--- + +## Missing Specifications + +### 10. No Testing Strategy + +The current codebase has 17 test files. The v2 plan mentions zero tests. For a system this complex, there's no mention of: +- Unit testing approach for graph operations +- Integration testing for multi-agent orchestration +- How to test concurrent worker behavior +- Mock strategies for LLM calls during testing +- How to test the daemon/API endpoints +- Performance/load testing for SQLite under concurrent access +- End-to-end testing strategy + +### 11. No Migration Path from V1 + +The current system uses JSON spec files. The plan says nothing about: +- Whether existing specs can be imported into the new graph +- Whether the CLI remains backward-compatible during transition +- How users transition from `rustagent run ` to `rustagent run --project "goal"` +- Whether the TUI (which is already built with 15 files) is carried forward or abandoned + +The TUI is a significant existing investment (ratatui-based, multiple views) that isn't mentioned in the v2 plan at all. Is it replaced by the web UI? Does it coexist? + +### 12. No Token/Cost Management + +For a system that spawns multiple concurrent LLM workers, there's no mention of: +- Token budget per worker, per task, per goal, or per session +- Cost tracking or reporting +- Circuit breakers if spending exceeds thresholds +- How `max_tokens` interacts with context window management +- Whether different workers can use different models (cheap model for planning, expensive for coding) + +### 13. No Observability Beyond WebSocket Events + +The plan lists WebSocket events for the web UI but doesn't address: +- Structured logging for the orchestrator and workers +- Metrics collection (task completion rate, agent utilization, retry rates) +- How to debug a misbehaving worker after the fact +- Whether worker LLM conversations are persisted for audit/debugging +- Alerting on failures + +### 14. No Git Integration Details + +`tools/git.rs` is listed but never specified. For a coding agent, git is critical: +- What git operations are supported? (commit, branch, diff, status, merge?) +- How do parallel workers interact with git? (separate branches? worktrees?) +- What happens when two workers modify the same file through git? +- How does git interact with the file ownership map? +- Is there a strategy for atomic commits per work package? + +### 15. No Graceful Degradation + +The plan describes the happy path thoroughly but doesn't address: +- What happens if sqlite-vec fails to load? (The plan mentions `instant-distance` as fallback but with zero detail) +- What happens if the embedding provider is unavailable? Does the system work without memory? +- What happens if the daemon crashes while workers are running? +- What happens if disk is full and SQLite can't write? + +--- + +## Questionable Technical Decisions + +### 16. sqlite-vec: Pre-v1 With Maintenance Concerns + +Research findings: +- Pre-v1 with explicit warning: "expect breaking changes" +- No updates for ~6 months as of recent reports +- **Brute-force only** — no ANN indexing (linear scan) +- Performance degrades significantly beyond 500K vectors +- This is fine for the memory system (small scale), but the "pre-v1 with stale maintenance" risk should be acknowledged + +The fallback to `instant-distance` is mentioned once without any detail on how switching would work, what the API differences are, or whether the schema changes. + +### 17. Deterministic Orchestrator: Power vs. Adaptability Trade-off + +The plan explicitly states the orchestrator is "NOT an LLM agent" but a "deterministic state machine." This is presented as purely beneficial. But it creates blind spots: + +- **Work package grouping** requires predicting file scope before execution — but an agent often discovers it needs to modify files not in its original scope +- **Task dependency resolution** is static — but tasks discovered during execution need dynamic re-planning +- A deterministic scheduler can't handle "this task turned out to be three tasks" without a planner agent re-intervening +- The plan's own `NeedsDecision` message implies the orchestrator sometimes needs judgment it can't provide + +The plan should specify: what triggers re-planning? How does the orchestrator handle scope changes? Is there a feedback loop where workers can request work package modifications? + +### 18. "Hybrid Messaging" Needs More Rigor + +The plan says "Orchestrator controls lifecycle + agents can message peers directly" but peer messaging is only mentioned for review workflows. This creates ambiguity: +- Can a coder worker ask another coder worker a question? +- What's the message delivery guarantee? (fire-and-forget? at-least-once?) +- What happens to in-flight messages when a worker is cancelled? +- Is the message bus persisted or in-memory only? + +The plan says communication is "primarily through the shared SQLite database" but then defines a `WorkerMessage` enum with multiple variants for direct messaging. Which is it? + +### 19. Session Model: Handoff Notes Are LLM-Generated + +"Orchestrator generates handoff notes summarizing: what was done, what's left, blockers, decisions made." But the orchestrator is explicitly NOT an LLM agent. So who generates these notes? If it's a final LLM call, that's not specified. If it's template-based from graph state, that's not specified either. + +--- + +## Structural Concerns + +### 20. Phase 1 Is Too Large + +Phase 1 contains 11 items including: full database schema, project management, complete graph model with 7 node types and 7 edge types, dependency resolution, ready surfacing, ADR export, TOML import/export/diff, session management, agent trait definition, runtime refactoring, config extension, cherry-picking existing modules, all graph tools, CLI wiring. + +This is realistically 3-4 phases of work collapsed into one. There's no way to get feedback on the database design before building the TOML interchange format on top of it. Recommended split: +- Phase 1a: Database + schema + basic CRUD +- Phase 1b: Graph model + node lifecycle + dependency resolution +- Phase 1c: Session model + ADR export + TOML interchange +- Phase 1d: Agent trait + runtime refactor + CLI + +### 21. Web UI Tech Choices Need Justification + +"React 19 + TypeScript + Bun + Vite" is stated without discussing: +- Why React over lighter alternatives (the UI is essentially a dashboard) +- State management approach (the stores are listed but no library is specified — Zustand? Redux? React Context?) +- Whether server-side rendering matters +- Bundle size considerations +- Whether Bun is production-ready enough for the build toolchain + +### 22. No Versioning/Schema Migration Strategy + +The database schema is defined once. There's no mention of: +- Schema versioning +- Migration tooling (hand-rolled? refinery? sqlx-migrate?) +- Backward compatibility when schema changes +- How TOML export format versioning works (`version = 1` is mentioned but no evolution strategy) + +--- + +## Summary + +| Category | Count | Severity | +|----------|-------|----------| +| Critical (will cause failures) | 3 | Must fix before implementation | +| Significant gaps | 6 | Will block implementation of specific phases | +| Missing specifications | 6 | Will require design decisions during implementation | +| Questionable decisions | 4 | Should be revisited with explicit trade-off analysis | +| Structural concerns | 3 | Affect project execution, not correctness | + +**Top 5 action items:** +1. Fix the ID scheme (8 hex chars minimum, or sibling-only uniqueness with full-path PKs) +2. Add a database concurrency section (write serialization, `BEGIN IMMEDIATE`, retry logic) +3. Specify agent profiles and autonomy levels fully +4. Split Phase 1 into 4 sub-phases +5. Add LLM failure handling and token budget management to the orchestrator spec + +--- + +## Research Sources + +### sqlite-vec +- [GitHub - asg017/sqlite-vec](https://github.com/asg017/sqlite-vec) +- [Introducing sqlite-vec v0.1.0](https://alexgarcia.xyz/blog/2024/sqlite-vec-stable-release/index.html) +- [Using sqlite-vec in Rust](https://alexgarcia.xyz/sqlite-vec/rust.html) +- [API Reference](https://alexgarcia.xyz/sqlite-vec/api-reference.html) +- [GitHub - djc/instant-distance](https://github.com/djc/instant-distance) + +### SQLite Concurrency +- [SQLite WAL Documentation](https://sqlite.org/wal.html) +- [SQLite File Locking (Locking v3)](https://sqlite.org/lockingv3.html) +- [Bert Hubert - SQLITE_BUSY Despite Timeout](https://berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout/) +- [tenthousandmeters - SQLite Concurrent Writes](https://tenthousandmeters.com/blog/sqlite-concurrent-writes-and-database-is-locked-errors/) +- [tokio-rusqlite Documentation](https://docs.rs/tokio-rusqlite/latest/tokio_rusqlite/) +- [rusqlite Transaction Behavior](https://docs.rs/rusqlite/latest/rusqlite/enum.TransactionBehavior.html) +- [SQLite Atomic Commit](https://sqlite.org/atomiccommit.html) + +### Birthday Problem / ID Collisions +- Standard birthday problem formula: P(collision) = 1 - e^(-N(N-1) / (2D)) where D = 2^16 = 65,536 +- Birthday bound for 50% collision: N = sqrt(2D * ln(2)) ≈ 301 IDs diff --git a/docs/plans/v2-architecture.md b/docs/plans/v2-architecture.md new file mode 100644 index 0000000..3264750 --- /dev/null +++ b/docs/plans/v2-architecture.md @@ -0,0 +1,2170 @@ +# Rustagent v2: Autonomous Multi-Agent Coding System + +## Vision +Clean-slate redesign of rustagent as an autonomous coding agent with multi-agent orchestration, task tracking, decision graphs, AGENTS.md support, and configurable autonomy. + +Inspired by: **Deciduous** (decision graphs), **Chainlink** (session-based task tracking with handoff notes), **Beads** (hash-based hierarchical IDs, dependency-aware task graphs, `ready` surfacing). + +### Relationship to V1 + +V2 is a **clean break**, not a migration. The v1 JSON spec format, planning agent, Ralph loop, and ratatui TUI are all superseded. There is no import tooling or backward compatibility — v1 was only used by the author. Selected modules are cherry-picked into v2 (LLM clients, security, tools) as listed in the "Files to Cherry-Pick" section. The TUI is replaced entirely by the web UI. + +## Key Design Decisions + +- **Tokio channels over actor frameworks** - Agent count is small (<10), broadcast + per-agent mpsc +- **SQLite for all persistence** - Single binary, WAL mode for concurrent reads +- **FTS5 for full-text search** - SQLite built-in, no external dependencies; covers graph node search without embedding infrastructure +- **Agent profiles in TOML config** - Customizable without recompiling; builtin defaults as fallbacks +- **Hybrid messaging** - Orchestrator controls lifecycle + agents can message peers directly +- **Unified work graph** - Tasks, decisions, outcomes, and observations are all nodes in a single DAG. 7 node types, 7 edge types. Eliminates duplication between separate task and decision systems (inspired by Deciduous, Chainlink, Beads) +- **Session model from Chainlink** - Handoff notes preserve context across sessions (temporal, separate from graph) +- **Hash-based IDs from Beads** - Merge-safe, hierarchical (goal.task.subtask) + +--- + +## Daemon + Web UI Architecture + +### Dual-Mode Operation + +Rustagent supports two modes: + +1. **Standalone CLI**: Direct execution for one-off commands, scripting, CI. Works without a running daemon. +2. **Daemon mode**: Long-running background process with HTTP API + WebSocket. Required for the web UI and for long-running orchestration. + +The CLI auto-detects whether a daemon is running (via PID file / health check) and routes commands accordingly: +- Daemon running → CLI becomes thin client, sends API requests +- No daemon → CLI executes directly (standalone mode) + +### Daemon + +``` +rustagent daemon start # Start daemon in background +rustagent daemon stop # Stop daemon +rustagent daemon status # Check if daemon is running +rustagent daemon logs # Tail daemon logs +``` + +The daemon is the same Rust binary with a `daemon` subcommand. It: +- Starts an HTTP server (axum) on a configurable port (default: `127.0.0.1:7400`) +- Runs the orchestrator for active goals +- Exposes REST API for CRUD operations +- Exposes WebSocket endpoint for real-time updates +- Writes PID file to `~/.local/share/rustagent/rustagent.pid` +- Logs to `~/.local/state/rustagent/logs/` (same as current) + +```rust +// src/daemon/mod.rs + +pub struct Daemon { + config: Config, + db: Arc, + orchestrators: HashMap, // One per active goal + ws_broadcaster: broadcast::Sender, +} +``` + +### HTTP API + +``` +# Projects +GET /api/projects # List all projects +POST /api/projects # Register a project +GET /api/projects/:id # Get project details +DELETE /api/projects/:id # Remove project + +# Work Graph (unified nodes + edges) +GET /api/projects/:id/goals # List goal nodes for project +POST /api/projects/:id/goals # Create a goal (starts orchestration) +GET /api/nodes/:id # Get any node with its edges +PATCH /api/nodes/:id # Update node (status, metadata) +POST /api/nodes/:id/children # Create child node +GET /api/goals/:id/tree # Get full node tree under goal + +# Task Views (projections of the work graph) +GET /api/goals/:id/tasks # List task nodes for goal +GET /api/goals/:id/tasks/ready # Get ready task nodes +GET /api/goals/:id/tasks/next # Get recommended next task + +# Decision Views (projections of the work graph) +GET /api/projects/:id/decisions # Active decisions (now mode) +GET /api/projects/:id/decisions/history # Full decision graph (history mode) +POST /api/projects/:id/decisions/export # Export ADRs to project dir + +# Graph Import/Export +GET /api/projects/:id/graph/export # Export all goals as TOML +GET /api/goals/:id/export # Export single goal as TOML +POST /api/projects/:id/graph/import # Import TOML (body: file content) +POST /api/projects/:id/graph/diff # Diff TOML against DB state + +# Sessions +GET /api/goals/:id/sessions # List sessions +GET /api/sessions/:id # Get session with handoff notes + +# Search (full-text search over graph nodes) +POST /api/projects/:id/search # FTS5 search over node titles/descriptions + +# Agents (real-time) +GET /api/goals/:id/agents # List active agents for goal + +# WebSocket +WS /ws # Real-time event stream +``` + +### WebSocket Events + +```typescript +type WsEvent = + | { type: "agent_spawned"; agentId: string; profile: string; goalId: string } + | { type: "agent_progress"; agentId: string; turn: number; summary: string } + | { type: "agent_completed"; agentId: string; outcome: AgentOutcome } + | { type: "node_created"; node: GraphNode } + | { type: "node_status_changed"; nodeId: string; nodeType: string; oldStatus: string; newStatus: string } + | { type: "edge_created"; edge: GraphEdge } + | { type: "session_ended"; sessionId: string; handoffNotes: string } + | { type: "tool_execution"; agentId: string; tool: string; args: object; result: string } + | { type: "orchestrator_state_changed"; goalId: string; state: string } +``` + +### Web UI + +**Stack**: TypeScript + Bun + Vite + Svelte 5 + +**Rationale**: Svelte for minimal boilerplate, built-in reactivity (runes — no separate state management library needed), and smallest runtime footprint. WebSocket-driven updates integrate naturally with Svelte's reactive stores. Graph visualization uses Cytoscape.js (framework-agnostic, handles pan/zoom/drag/expand-collapse for interactive decision and goal graph views). Bun is build toolchain only (via Vite); runtime is the browser. No SSR — this is a locally-served SPA. + +**Location**: `web/` directory in the repo (separate from `src/`) + +``` +web/ +├── package.json +├── svelte.config.js +├── tsconfig.json +├── vite.config.ts +├── bun.lock +├── index.html +├── src/ +│ ├── main.ts # Entry point +│ ├── App.svelte # Root component + routing +│ ├── api/ +│ │ ├── client.ts # HTTP API client +│ │ └── websocket.ts # WebSocket connection + event handling +│ ├── stores/ # Svelte runes-based reactive stores +│ │ ├── projects.svelte.ts +│ │ ├── graph.svelte.ts # Unified: nodes, edges, goals, tasks, decisions +│ │ ├── agents.svelte.ts +│ │ └── search.svelte.ts # Full-text search over graph nodes +│ ├── views/ +│ │ ├── Dashboard.svelte # Overview: active goals, agent status, recent activity +│ │ ├── ProjectList.svelte # All projects +│ │ ├── ProjectDetail.svelte # Goals, tasks, decisions for a project +│ │ ├── TaskTree.svelte # Projection: task nodes with hierarchy and status +│ │ ├── DecisionGraph.svelte # Projection: decision/option/outcome nodes (now + history modes) +│ │ ├── GraphSearch.svelte # Full-text search across graph nodes +│ │ ├── AgentMonitor.svelte # Real-time agent activity (tool calls, progress) +│ │ └── SessionHistory.svelte # Past sessions with handoff notes +│ ├── components/ +│ │ ├── GraphNodeCard.svelte +│ │ ├── CytoscapeGraph.svelte # Wrapper for Cytoscape.js (pan/zoom/drag graph views) +│ │ ├── AgentStatusBadge.svelte +│ │ ├── SearchResult.svelte +│ │ └── ... +│ └── styles/ +└── public/ +``` + +**Key views:** + +- **Dashboard**: At-a-glance view of all active goals across projects, running agents, task completion %, recent decisions +- **Task Tree**: Projection of the work graph showing goal → tasks → subtasks with dependency edges, status colors, agent assignments +- **Decision Graph**: Projection of the work graph filtering to decision/option/outcome/revisit nodes. Toggle between Now mode (active decisions only) and History mode (full evolution). Click nodes to see details. +- **Agent Monitor**: Real-time feed of agent activity - tool calls, file changes, progress reports. Like watching multiple terminal sessions. +- **Graph Search**: Full-text search across all graph nodes (observations, outcomes, decisions). Filter by node type, project, status. + +### Serving the Web UI + +Two modes, controlled by a Cargo feature flag: + +1. **Development** (default): No frontend build during `cargo build`. Developers run `bun run dev` in `web/` for Vite's dev server with HMR, proxying API calls to the daemon. The daemon does not serve the UI — if no embedded assets exist and no `web/dist/` is found, `/*` returns a message directing the user to start the Vite dev server or build with `--features bundle-ui`. + +2. **Release / single-binary** (`--features bundle-ui`): `build.rs` runs `bun install && bun run build` in `web/`, then `rust-embed` compiles `web/dist/` into the binary. The daemon serves embedded assets directly — no external files needed. One binary, fully self-contained. + +**`build.rs`** (frontend build, only with `bundle-ui` feature): + +```rust +fn main() { + #[cfg(feature = "bundle-ui")] + { + println!("cargo:rerun-if-changed=web/src"); + println!("cargo:rerun-if-changed=web/package.json"); + + let web_dir = "web"; + + let status = std::process::Command::new("bun") + .args(["install"]) + .current_dir(web_dir) + .status() + .expect("bun must be installed to build with bundle-ui"); + assert!(status.success(), "bun install failed"); + + let status = std::process::Command::new("bun") + .args(["run", "build"]) + .current_dir(web_dir) + .status() + .expect("bun run build failed"); + assert!(status.success(), "frontend build failed"); + } +} +``` + +**Embedded assets** (behind `bundle-ui` feature): + +```rust +#[cfg(feature = "bundle-ui")] +#[derive(rust_embed::Embed)] +#[folder = "web/dist/"] +struct UiAssets; +``` + +The daemon's axum fallback handler tries `UiAssets::get(path)`, falling back to `UiAssets::get("index.html")` for SPA routing. Content types are inferred by `rust-embed`. + +**The daemon's axum server serves:** +- `/api/*` → REST API +- `/ws` → WebSocket +- `/*` → Embedded UI assets (with `bundle-ui`) or "UI not bundled" message (without) + +### New Rust Dependencies for Daemon + +```toml +axum = { version = "0.8", features = ["ws"] } # HTTP server + WebSocket +tower = "0.5" # Middleware +tower-http = { version = "0.6", features = ["cors", "fs"] } # CORS + static files +rust-embed = { version = "8", features = ["axum"], optional = true } # Static asset embedding +``` + +--- + +## Multi-Project Architecture + +### Central Database + +All data lives in a single SQLite database at `~/.local/share/rustagent/rustagent.db` (XDG data dir). This enables: +- Cross-project search (observations from project A are findable when working on project B) +- Unified task/decision views across all projects +- Single source of truth + +### Project Registration + +Projects are explicitly registered: + +``` +rustagent project add my-api /Users/david/code/my-api +rustagent project add frontend /Users/david/code/frontend +rustagent project list +rustagent project remove my-api +rustagent project show my-api +``` + +```rust +pub struct Project { + pub id: String, // Auto-generated hash (ra-xxxx) + pub name: String, // Friendly name (e.g., "my-api") + pub path: PathBuf, // Absolute path to project root + pub registered_at: DateTime, + pub config_overrides: Option, // Per-project config + pub metadata: HashMap, +} +``` + +**Project resolution**: When running commands, specify project by name: +``` +rustagent run --project my-api "Add user authentication" +rustagent tasks --project my-api +rustagent status --project my-api +``` + +If no `--project` flag, rustagent looks for a registered project matching the current working directory. + +### Data Scoping + +All entities are scoped to a project via `nodes.project_id`: +- Goals, tasks, decisions, options, outcomes — all node types inherit project scope +- Sessions also carry `project_id` +- Queries default to the current project +- Full-text search can optionally span all projects (for cross-project learnings) + +### ADR Export + +Despite the central database, ADR export writes to the project's directory: +``` +rustagent decisions export --project my-api +# → /Users/david/code/my-api/decisions/001-auth-approach.md +``` + +### Database Schema Addition + +```sql +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + registered_at TEXT NOT NULL, + config_overrides TEXT, -- JSON + metadata TEXT NOT NULL DEFAULT '{}' +); + +-- Other tables with project_id: +-- nodes.project_id REFERENCES projects(id) (all node types: goals, tasks, decisions, etc.) +-- sessions.project_id REFERENCES projects(id) +``` + +--- + +## Database Concurrency Strategy + +### Single Connection, WAL Mode + +All database access goes through a single `tokio_rusqlite::Connection` shared via `Arc`. This is the entire write serialization strategy — `tokio-rusqlite` runs one SQLite connection on a dedicated background thread and processes `.call()` closures sequentially through an internal channel. No additional write queue or mutex is needed. + +**Configuration at connection open:** +```sql +PRAGMA journal_mode = WAL; -- Concurrent reads, serialized writes +PRAGMA busy_timeout = 5000; -- 5s safety net (shouldn't trigger with single connection) +PRAGMA foreign_keys = ON; +PRAGMA wal_autocheckpoint = 1000; -- Default, tune only if profiling shows need +``` + +### Transaction Discipline + +All write transactions use `BEGIN IMMEDIATE` to prevent the reader-to-writer upgrade deadlock (where a `DEFERRED` transaction that starts reading, then tries to write, gets an immediate `SQLITE_BUSY` that `busy_timeout` cannot help with). + +```rust +conn.call(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + // ... writes ... + tx.commit()?; + Ok(()) +}).await?; +``` + +### Atomic Task Claiming + +`claim_task` is implemented as a single conditional UPDATE — no read-then-write race: + +```sql +UPDATE nodes SET status = 'claimed', assigned_to = ?1, started_at = ?2 +WHERE id = ?3 AND status = 'ready'; +``` + +If `changes() == 1`, the claim succeeded. If `changes() == 0`, another worker got there first. The caller retries with a different ready task. + +### Future Scaling + +At <10 agents, a single connection is sufficient. If read latency ever becomes an issue, the first optimization would be a second read-only connection (WAL allows concurrent readers alongside one writer). This is an optimization to add if profiling shows need, not an upfront design requirement. + +--- + +## Unified Work Graph + +### Core Model + +The work graph is a single directed acyclic graph (DAG) that unifies task tracking and decision recording. Every entity — goals, tasks, decisions, options, outcomes, observations, and pivots — is a node in the same graph. Relationships between them are edges. + +This eliminates the duplication between separate task and decision systems: dependencies, blocking, hierarchy, and cross-references are all expressed once through edges. Task trees and decision histories are different views (projections) of the same underlying graph. + +### IDs + +Hierarchical IDs with sibling-unique segments (inspired by Beads): +- `ra-a3f8` (Goal) +- `ra-a3f8.1` (Task or Decision under that goal) +- `ra-a3f8.1.3` (Subtask or Option) + +The **full dotted path is the primary key** (e.g., `ra-a3f8.1.3`). Each segment only needs to be unique among its siblings under one parent, not globally unique. This means: + +- **Goal IDs**: `ra-` prefix + 4 hex chars from UUID v4. With 65,536 possibilities, collision at 50% requires ~301 *goals* — more than enough headroom. +- **Child IDs**: Sequential integer counter per parent (`.1`, `.2`, `.3`). The parent node serializes child creation, so no concurrency concern. Counter is stored on the parent node in metadata (`next_child_seq`). +- **Primary key**: The full path string (`ra-a3f8.1.3`). Globally unique by construction since each segment is sibling-unique and the path encodes the full hierarchy. + +This keeps IDs short, human-friendly, and collision-safe at any scale. The hierarchical structure is encoded directly in the ID rather than being a display convenience — `Contains` edges still exist for queryability, but the ID itself is authoritative for parentage. + +**Edge IDs**: `e-` prefix + 8 hex chars from UUID v4 (e.g., `e-a3f8b2c1`). Edges are global, not hierarchical, so they use a flat namespace with enough entropy to avoid collisions. + +### Node Types + +7 node types cover the full workflow: + +```rust +// src/graph/mod.rs + +pub enum NodeType { + Goal, // High-level objective driving all work + Task, // Work item to be executed by an agent + Decision, // Choice point where alternatives are evaluated + Option, // Specific approach considered for a decision + Outcome, // Result of completed work or action + Observation, // Discovery or insight during development + Revisit, // Pivot point — abandoned approach leads to new decision +} +``` + +**Goal**: Top-level objective. Contains Tasks and Decisions via `Contains` edges. Has a priority. + +**Task**: Concrete work item. Can contain subtasks (also Task nodes). The workhorse — agents claim and execute these. Carries acceptance criteria and assignment in metadata. + +**Decision**: A choice point. Connected to Option nodes via `LeadsTo` edges. Resolved when an option is chosen. + +**Option**: An alternative considered for a Decision. Carries pros/cons in metadata. Status becomes `Chosen` or `Rejected`. + +**Outcome**: The result of completing a Task or choosing an Option. Records success/failure and what happened. + +**Observation**: An insight or discovery. Connected to any node via `Informs` edges. Replaces the old `learnings` field — observations are first-class graph citizens. + +**Revisit**: A pivot point created when an Outcome is bad. Connects the failed path to a new Decision, preserving the full reasoning chain. + +### Edge Types + +7 edge types express all relationships: + +```rust +pub enum EdgeType { + Contains, // Hierarchical: Goal → Task, Task → Subtask, Goal → Decision + DependsOn, // Sequencing: B depends on A (A must complete first) + LeadsTo, // Narrative: Decision → Options, Task → Outcome, Outcome → Revisit + Chosen, // Selection: Decision → the selected Option + Rejected, // Selection: Decision → a rejected Option (label has reason) + Supersedes, // Evolution: new node replaces old + Informs, // Context: Observation/Outcome provides context to another node +} + +pub struct GraphEdge { + pub id: String, + pub edge_type: EdgeType, + pub from_node: String, + pub to_node: String, + pub label: Option, // e.g., rejection reason, dependency description + pub created_at: DateTime, +} +``` + +### Node Status + +A single status enum with type-appropriate semantics: + +```rust +pub enum NodeStatus { + // Lifecycle (all node types) + Pending, // Created, not yet actionable + Active, // Currently relevant + Completed, // Done successfully + Cancelled, // No longer needed + + // Task workflow + Ready, // All dependencies met, available for claiming + Claimed, // Agent has atomically claimed this + InProgress, // Active work underway + Review, // Work done, awaiting review + Blocked, // Cannot proceed (blocked_reason set) + Failed, // Attempted and failed + + // Decision workflow + Decided, // Decision resolved (a chosen option exists) + Superseded, // Replaced by newer approach + Abandoned, // Tried and rejected + + // Option workflow + Chosen, // This option was selected + Rejected, // This option was not selected +} + +pub enum Priority { + Critical, // Must do immediately + High, // Should do soon + Medium, // Normal priority + Low, // Nice to have +} +``` + +Not all statuses apply to all node types. Validation rules: +- **Goal**: Pending, Active, Completed, Cancelled +- **Task**: Pending, Ready, Claimed, InProgress, Review, Completed, Blocked, Failed, Cancelled +- **Decision**: Pending, Active, Decided, Superseded +- **Option**: Pending, Active, Chosen, Rejected, Abandoned +- **Outcome**: Active, Completed +- **Observation**: Active +- **Revisit**: Active, Completed + +### The Unified Node + +```rust +pub struct GraphNode { + pub id: String, // Hash-based: ra-xxxx, ra-xxxx.1, etc. + pub project_id: String, + pub node_type: NodeType, + pub title: String, + pub description: String, + pub status: NodeStatus, + pub priority: Option, // Goals and Tasks + pub assigned_to: Option, // Tasks + pub created_by: Option, + pub labels: Vec, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, + pub blocked_reason: Option, + pub metadata: HashMap, // Type-specific data +} +``` + +**Metadata by node type:** +- **Task**: `acceptance_criteria` (JSON array), `estimated_complexity` +- **Option**: `pros` (JSON array), `cons` (JSON array) +- **Outcome**: `success` (bool) +- **Revisit**: `pivot_reason` + +### How It All Connects + +``` +┌──────┐ contains ┌──────────┐ contains ┌──────────┐ +│ Goal │────────────→│ Task A │────────────→│Subtask A1│ +└──────┘ └──────────┘ └──────────┘ + │ │ + │ contains leads_to + ↓ ↓ +┌──────────┐ ┌──────────┐ +│ Decision │ │ Outcome │──→ (if bad) ──→ Revisit ──→ New Decision +└──────────┘ └──────────┘ + │ + │ leads_to + ├───────────────────┐ + ↓ ↓ +┌──────────┐ ┌──────────┐ +│ Option A │ │ Option B │ +│ (chosen) │ │(rejected)│ +└──────────┘ └──────────┘ + +┌──────────┐ depends_on ┌──────────┐ +│ Task B │─────────────→│ Task A │ +└──────────┘ └──────────┘ +``` + +### Task Lifecycle (State Machine) + +Task nodes follow this state machine: + +``` + ┌──────────┐ + │ Cancelled│ + └──────────┘ + ↑ +┌─────────┐ deps met ┌───────┐ agent ┌─────────┐ +│ Pending │──────────────→│ Ready │─claims──→│ Claimed │ +└─────────┘ └───────┘ └─────────┘ + ↑ │ + │ work starts + │ ↓ + unblocked ┌────────────┐ + │ │ InProgress │ + │ └────────────┘ + ┌─────────┐ │ │ + │ Blocked │←───────────┘ │ + └─────────┘ work done │ + ↓ + ┌────────┐ + ┌─────────┐ │ Review │ + │ Failed │←─────────┴────────┘ + └─────────┘ │ + approved + ↓ + ┌──────────┐ + │ Complete │ + └──────────┘ +``` + +Key transitions: +- **Pending → Ready**: Automatic when all `DependsOn` edges point to Completed nodes +- **Ready → Claimed**: Atomic (sets assigned_to + status) — prevents two agents claiming same task +- **Claimed → InProgress**: Agent begins work +- **InProgress → Blocked**: Agent encounters blocker, creates Observation node explaining why +- **Blocked → Ready**: Blocker resolved, re-enters the queue +- **InProgress → Review**: Work complete, optional review gate +- **Review → Complete**: Reviewer approves (or auto-complete if no review gate) +- **Review → Failed**: Reviewer rejects, needs rework +- **Any → Cancelled**: Goal changed, task no longer needed + +### Decision Workflow (Forward Logging) + +Agents follow a forward-logging pattern (from Deciduous): + +1. **Log intention**: Create Decision node under current Goal (via `Contains` edge), add Option nodes via `LeadsTo` edges +2. **Choose**: Add `Chosen` edge from Decision to selected Option, `Rejected` edges (with reason labels) to others. Decision status → `Decided`. +3. **Execute**: Chosen Option may generate Task nodes (via `Contains` edges from the Goal) +4. **Record outcome**: Task completion creates Outcome node (via `LeadsTo` edge from Task) +5. **Pivot if needed**: Bad Outcome → create Revisit node (via `LeadsTo`) → new Decision node (via `LeadsTo`) + +This creates an unbroken reasoning chain explaining why the codebase looks the way it does. + +### When Decisions Are Created + +Agents create decision records when: +- **Architectural choices**: Picking patterns, libraries, data structures +- **Trade-off moments**: Performance vs. readability, simplicity vs. flexibility +- **Multiple valid approaches**: Agent considers 2+ options before choosing +- **Blockers encountered**: Why something was blocked and what was tried +- **Pivots**: Abandoning one approach for another (Revisit node) + +The orchestrator can also require decisions at approval gates (configurable). + +### ADR Export + +Despite the unified graph, ADR export works by filtering to Decision nodes and gathering their connected Options (Chosen/Rejected), Outcomes, and related Tasks: + +``` +decisions/ +├── 001-authentication-approach.md +├── 002-database-choice.md +└── 003-api-design.md +``` + +Each ADR follows the format: +```markdown +# ADR-001: Authentication Approach + +## Status: Active +## Context: [from Decision node description] +## Options Considered: +- **Option A**: [description] - CHOSEN + - Pros: ... + - Cons: ... +- **Option B**: [description] - REJECTED + - Reason: ... +## Outcome: [from Outcome node] +## Related Tasks: ra-a3f8.2, ra-a3f8.3 +``` + +### Two Decision Visualization Modes (from Deciduous) + +**Now Mode** (`rustagent decisions now`): Filter the graph to Active/Decided Decision and Chosen Option nodes. Shows how the system works today. + +**History Mode** (`rustagent decisions history`): Full graph including Abandoned, Superseded, and Rejected nodes. Explains why things changed. + +### Session Model (from Chainlink) + +Sessions are separate from the graph — they're temporal, not structural: + +```rust +pub struct Session { + pub id: String, + pub goal_id: String, + pub started_at: DateTime, + pub ended_at: Option>, + pub handoff_notes: Option, // Generated at session end + pub agent_ids: Vec, // Agents that participated + pub summary: Option, // Auto-generated session summary +} +``` + +Each `rustagent run` creates a session. When a session ends (goal complete, user interrupt, error): +1. Orchestrator generates handoff notes **deterministically from graph state** (no LLM call). The template queries nodes by status and formats them: + +``` +## Done +- ra-a3f8.1: Design auth schema (completed) +- ra-a3f8.2: JWT vs sessions → JWT chosen + +## Remaining +- ra-a3f8.3: Implement JWT middleware (ready) +- ra-a3f8.4: Add refresh token rotation (pending, blocked by ra-a3f8.3) + +## Blocked +- None + +## Decisions Made +- ra-a3f8.2: JWT tokens chosen over server-side sessions (stateless, scalable) +``` + +2. Handoff notes are stored in the session record +3. Next `rustagent run` on the same goal loads previous handoff notes into worker context + +### Smart Task Surfacing + +**`ready` command** (from Beads): Query Task nodes where all `DependsOn` targets are Completed and status is Ready. + +**`next` command** (from Chainlink): Recommend the highest-priority Ready task, considering: +1. Priority level (Critical > High > Medium > Low) +2. Number of downstream nodes it unblocks (prefer tasks that unblock the most) +3. Estimated complexity (simpler tasks first for momentum) + +### Node Decay for Context (from Beads) + +Old completed nodes are compacted when injected into agent context to save tokens: +- Recent nodes (< 7 days): full detail (description, criteria, outcomes) +- Older nodes (7-30 days): summary only (title, status, key outcomes) +- Ancient nodes (> 30 days): just title and status +- Thresholds configurable in `.rustagent/config.toml` +- Full detail is always available via `query_nodes` regardless of age + +### User Visibility + +``` +rustagent status # Goal tree + agent states + session info +rustagent tasks # List task nodes (filterable by status, label, priority) +rustagent tasks ready # Show ready-to-claim tasks +rustagent tasks next # Recommend next task +rustagent tasks tree # Tree view: goal → tasks → subtasks +rustagent decisions # List active decisions +rustagent decisions now # Current truth — active decisions only +rustagent decisions history # Full evolution including abandoned paths +rustagent decisions show # Show decision with options and outcome +rustagent decisions export # Export as markdown ADR files +rustagent sessions # List sessions with handoff notes +rustagent sessions latest # Show most recent session's handoff notes +``` + +--- + +## Work Graph Import/Export + +### Motivation + +The canonical work graph lives in SQLite, but collaborators need to: +- Share graph state through git (review task breakdowns and decisions in PRs) +- Hand off work between team members (beyond just handoff notes) +- Back up and restore graph state +- Resolve divergent graph states after independent work + +### Format: TOML, One File Per Goal + +**Why TOML**: Excellent Rust support (`toml` crate + serde), human-readable, unambiguous spec, typed values. Table-per-entity maps naturally to git-friendly diffs where each node/edge is an independent hunk. + +**Why one file per goal**: Goals are the natural collaboration boundary. Independent goals produce independent files with zero merge conflicts across goals. File-level git operations (blame, log) work well at this granularity. + +### File Structure + +``` +.rustagent/ +├── config.toml # Project config (autonomy level, default profile, etc.) +├── profiles/ # Custom agent profiles (version-controlled) +│ └── rust-coder.toml +├── graph/ +│ ├── ra-a3f8.toml # Goal: "Add user authentication" +│ └── ra-b2c1.toml # Goal: "Optimize database queries" +└── sessions/ + ├── ra-a3f8-2025-01-15.toml + └── ra-a3f8-2025-01-16.toml +``` + +The `.rustagent/` directory lives at the project root and is intended to be committed to git (like `.github/`). + +### Goal File Format + +```toml +[meta] +version = 1 +goal_id = "ra-a3f8" +project = "my-api" +exported_at = "2025-01-15T10:30:00Z" +content_hash = "abc123def456" # Blake3 hash of nodes+edges for quick change detection + +# ─── Nodes ────────────────────────────────────────────── +# Each [nodes.""] block is an independent git hunk. +# Sorted by ID for deterministic output. + +[nodes."ra-a3f8"] +type = "goal" +title = "Add user authentication" +description = "Implement JWT-based auth with refresh tokens" +status = "active" +priority = "high" +labels = ["security", "mvp"] +created_at = "2025-01-15T10:00:00Z" + +[nodes."ra-a3f8.1"] +type = "task" +title = "Design auth schema" +description = "Create database tables for users, tokens, sessions" +status = "completed" +priority = "high" +assigned_to = "coder-1" +created_at = "2025-01-15T10:01:00Z" +started_at = "2025-01-15T10:02:00Z" +completed_at = "2025-01-15T10:30:00Z" + +[nodes."ra-a3f8.1".metadata] +acceptance_criteria = [ + "Users table with email + hashed password", + "Refresh token table with expiry", +] + +[nodes."ra-a3f8.2"] +type = "decision" +title = "JWT vs session tokens" +description = "Choose authentication token strategy" +status = "decided" +created_at = "2025-01-15T10:05:00Z" + +[nodes."ra-a3f8.2.1"] +type = "option" +title = "JWT tokens" +description = "Stateless JWT with short-lived access + long-lived refresh" +status = "chosen" +created_at = "2025-01-15T10:05:00Z" + +[nodes."ra-a3f8.2.1".metadata] +pros = ["Stateless", "Horizontally scalable", "No server-side session store"] +cons = ["Can't revoke individual tokens", "Larger payload than session ID"] + +[nodes."ra-a3f8.2.2"] +type = "option" +title = "Server-side sessions" +description = "Traditional session cookie with server-side store" +status = "rejected" +created_at = "2025-01-15T10:05:00Z" + +[nodes."ra-a3f8.2.2".metadata] +pros = ["Simple revocation", "Small cookie size"] +cons = ["Requires session store", "Horizontal scaling needs shared store"] + +# ─── Edges ────────────────────────────────────────────── +# Each [edges.""] block is an independent git hunk. +# Sorted by ID for deterministic output. + +[edges."e-0001"] +type = "contains" +from = "ra-a3f8" +to = "ra-a3f8.1" + +[edges."e-0002"] +type = "contains" +from = "ra-a3f8" +to = "ra-a3f8.2" + +[edges."e-0003"] +type = "leads_to" +from = "ra-a3f8.2" +to = "ra-a3f8.2.1" + +[edges."e-0004"] +type = "leads_to" +from = "ra-a3f8.2" +to = "ra-a3f8.2.2" + +[edges."e-0005"] +type = "chosen" +from = "ra-a3f8.2" +to = "ra-a3f8.2.1" + +[edges."e-0006"] +type = "rejected" +from = "ra-a3f8.2" +to = "ra-a3f8.2.2" +label = "Requires server-side session store, adds operational complexity" +``` + +### Session File Format + +Sessions export alongside their goal in a separate directory: + +```toml +# .rustagent/sessions/ra-a3f8-2025-01-15.toml + +[meta] +session_id = "s-1234" +goal_id = "ra-a3f8" +started_at = "2025-01-15T10:00:00Z" +ended_at = "2025-01-15T12:30:00Z" +agents = ["planner-1", "coder-1"] + +summary = "Completed auth schema design and JWT decision" + +handoff_notes = """ +## Done +- Designed auth schema (ra-a3f8.1) +- Decided on JWT tokens (ra-a3f8.2) + +## Remaining +- Implement JWT middleware (ra-a3f8.3) +- Add refresh token rotation (ra-a3f8.4) + +## Blockers +- None +""" +``` + +### Git-Friendliness Properties + +1. **One table per entity**: Each `[nodes."id"]` and `[edges."id"]` block is an independent git hunk. Adding a node = adding lines at a predictable location (auto-mergeable). Modifying a node = changing lines within one block (conflicts only if the same entity is modified by both sides). + +2. **Deterministic ordering**: Nodes and edges sorted lexicographically by ID. Re-exporting unchanged state produces byte-identical output. No spurious diffs. + +3. **Hash-based IDs**: Concurrent node creation by different collaborators won't produce ID collisions (UUID v4-based). Two people can independently add tasks to the same goal and merge cleanly. + +4. **Content hash**: `content_hash` in `[meta]` enables quick "has anything changed?" checks without diffing the full file. Useful for CI hooks and auto-sync. + +5. **Omitted fields**: Null/empty fields are omitted entirely (no `assigned_to = ""` noise). Fields only appear when they carry meaningful data, keeping diffs minimal. + +### Conflict Resolution + +**Automatic (git handles it):** +- Collaborator A adds `ra-a3f8.3`, Collaborator B adds `ra-a3f8.4` → different hunks, clean merge +- Collaborator A adds edge `e-0007`, Collaborator B adds `e-0008` → different hunks, clean merge +- Collaborator A modifies `ra-a3f8.1`, Collaborator B adds `ra-a3f8.5` → different hunks, clean merge + +**Manual (git conflict markers):** +- Both modify the status of `ra-a3f8.1` → standard git conflict on the `status` line +- Both edit the description of the same node → standard conflict, human picks winner + +**Import-level conflict resolution:** + +When `rustagent graph import` encounters a node that exists in the DB with different content than the file, it applies one of three strategies: + +| Mode | New nodes | Changed nodes | Unchanged nodes | +|------|-----------|---------------|-----------------| +| `--merge` (default) | Added | Flagged as conflicts | Skipped | +| `--theirs` | Added | File wins | Skipped | +| `--ours` | Added | DB wins | Skipped | +| `--dry-run` | Shown | Shown | Shown | + +Conflict output (in `--merge` mode): +``` +$ rustagent graph import .rustagent/graph/ra-a3f8.toml + Added: ra-a3f8.5 (task: "Add rate limiting") + Added: e-0009 (depends_on: ra-a3f8.5 → ra-a3f8.3) + CONFLICT: ra-a3f8.1 status differs (db: in_progress, file: completed) + Skipped: 4 unchanged nodes, 6 unchanged edges + + 1 conflict. Resolve with: + rustagent graph import --theirs ra-a3f8.toml # Accept file version + rustagent graph import --ours ra-a3f8.toml # Keep DB version +``` + +### CLI + +``` +# Export +rustagent graph export # Export all goals for current project +rustagent graph export --goal ra-a3f8 # Export specific goal +rustagent graph export --output ./shared/ # Custom output directory +rustagent graph export --sessions # Include session files + +# Import +rustagent graph import .rustagent/graph/ # Import all goal files in directory +rustagent graph import .rustagent/graph/ra-a3f8.toml # Import specific file +rustagent graph import --dry-run ra-a3f8.toml # Preview what would change +rustagent graph import --theirs ra-a3f8.toml # File wins on conflicts +rustagent graph import --ours ra-a3f8.toml # DB wins on conflicts + +# Diff (compare file state to DB state) +rustagent graph diff .rustagent/graph/ra-a3f8.toml # Show differences for one goal +rustagent graph diff .rustagent/graph/ # Diff all files against DB +``` + +### Cross-Goal References + +Edges may occasionally cross goal boundaries (e.g., an Observation in Goal A `Informs` a Decision in Goal B). These edges are stored in the file of the `from_node`'s goal. + +On import, cross-goal node references are resolved by ID lookup in the DB. If the referenced node doesn't exist, the edge is **skipped with a clear error** — not silently dropped, not deferred: + +``` +$ rustagent graph import .rustagent/graph/ra-a3f8.toml + Added: 5 nodes, 8 edges + SKIPPED: 1 edge (e-0012: informs ra-b2c1.3 — node not found) + + To resolve: import the goal containing ra-b2c1 first, then re-import this file. +``` + +Re-importing after the referenced goal exists will create the edge (edge creation is idempotent). No deferred state or pending tables — the simplest correct behavior for a rare case. + +### Auto-Export Hook + +The daemon can optionally auto-export after graph mutations: + +```toml +# In rustagent config +[export] +auto_export = true # Write .rustagent/graph/ on every graph change +auto_export_sessions = false # Sessions only on explicit export +auto_export_debounce_ms = 1000 # Batch rapid changes +``` + +This keeps `.rustagent/graph/` in sync with the DB automatically. Combined with git hooks, teams can enforce that graph state is always committed alongside code changes. + +### Implementation Notes + +- Uses the `toml` crate with serde `Serialize`/`Deserialize` on graph types +- Custom serializer sorts keys lexicographically within `[nodes.*]` and `[edges.*]` sections +- Export is a pure read: `GraphStore::get_subtree(goal_id)` → TOML serialization → write file +- Import is TOML parse → diff against DB → apply with conflict strategy +- Content hash uses Blake3 over the serialized nodes+edges (excluding `[meta]`) +- Round-trip property: `export → import → export` produces identical files + +--- + +## Agent Orchestration System (Detailed) + +Inspired by: **Gastown** (Mayor + ephemeral workers, convoys, recovery-first), **Loom** (state machine coordination, thread persistence), plus first-principles thinking about what LLM-based agents actually need. + +### Core Insight: Ephemeral Workers Beat Long-Running Agents + +LLM agents degrade with long context windows - they lose focus, hallucinate more, and waste tokens on irrelevant history. The orchestration model embraces this: + +- **Workers are ephemeral**: Spawn fresh for each task, execute, report, terminate +- **State lives in SQLite, not in agent memory**: If anything crashes, resume from DB +- **Fresh context = focused work**: Each worker gets only the context it needs + +### Architecture: Orchestrator + Worker Pool + +``` + ┌─────────────────────────────┐ + │ Orchestrator │ + │ (persistent, state-machine │ + │ based coordination loop) │ + └──────────┬──────────────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ Worker A │ │ Worker B │ │ Worker C │ + │ (planner) │ │ (coder) │ │ (coder) │ + │ ephemeral │ │ ephemeral │ │ ephemeral │ + └────────────┘ └────────────┘ └────────────┘ + │ │ │ + └──────────────┼──────────────┘ + │ + ┌──────────▼──────────────┐ + │ SQLite (shared │ + │ state: work graph, │ + │ sessions) │ + └─────────────────────────┘ +``` + +### Orchestrator Design + +The orchestrator is NOT an LLM agent. It's a **deterministic state machine** that coordinates work. This is a deliberate choice - the orchestrator doesn't burn tokens on coordination logic; it follows rules. + +```rust +// src/agent/orchestrator.rs + +pub struct Orchestrator { + config: OrchestratorConfig, + graph_store: Arc, + message_bus: Arc, + active_workers: HashMap, + file_locks: FileOwnershipMap, // Which agent owns which files +} + +pub struct OrchestratorConfig { + pub max_concurrent_workers: usize, // Default: 4 + pub max_retries_per_task: usize, // Default: 2 + pub worker_turn_limit: usize, // Default: 100 + pub check_in_interval: usize, // Worker reports every N turns + pub review_required: bool, // Spawn reviewer after coder finishes + + // Error handling thresholds + pub max_consecutive_llm_failures: usize, // Default: 3. Persistent failures → TaskBlocked + pub max_consecutive_tool_failures: usize, // Default: 3. Bad tool calls → TaskBlocked + pub worker_token_budget: usize, // Default: 200_000. Per-worker token limit + pub token_budget_warning_pct: u8, // Default: 80. Inject "wrap up" at this % + pub max_tokens_per_goal: Option, // Optional. Pause + approval request if exceeded +} +``` + +### Orchestrator State Machine + +``` +┌───────────┐ +│ Startup │ Load config, connect DB, recover interrupted session +└─────┬─────┘ + │ + ▼ +┌───────────┐ no goal found +│ Loading │──────────────→ Create new goal from user input +└─────┬─────┘ + │ goal exists (possibly from previous session) + ▼ +┌───────────┐ no tasks yet +│ Planning │──────────────→ Spawn planner worker +└─────┬─────┘ + │ tasks exist + ▼ +┌───────────┐ +│ Scheduling │ Query ready tasks, group into work packages, +└─────┬─────┘ check file ownership constraints, spawn workers + │ + ▼ +┌───────────┐ workers running +│ Monitoring │ Wait for worker messages (progress, completion, blocks) +└─────┬─────┘ Handle timeouts, failures, new task creation + │ + ├──→ Worker completed → update task, back to Scheduling + ├──→ Worker blocked → record reason, back to Scheduling + ├──→ Worker failed → retry or mark failed, back to Scheduling + ├──→ All tasks complete → to Reviewing + └──→ Session interrupted → generate handoff notes, persist state + + ▼ +┌───────────┐ +│ Reviewing │ (if review_required) Spawn reviewer workers +└─────┬─────┘ + │ + ▼ +┌───────────┐ +│ Completing │ Generate session summary, store observations, +└───────────┘ report results to user +``` + +### Work Packages + +Instead of assigning one task to one worker (wasteful if tasks are small), the orchestrator groups related tasks into **work packages**: + +```rust +pub struct WorkPackage { + pub id: String, + pub task_ids: Vec, + pub file_scope: Vec, // Files this package touches + pub profile: String, // Which agent profile to use + pub priority: Priority, + pub estimated_complexity: Complexity, +} + +pub enum Complexity { + Small, // 1-2 file changes, straightforward + Medium, // Multiple files, some decision-making + Large, // Architectural changes, many files +} +``` + +**Grouping rules:** +1. Tasks that modify the same files → same work package (prevents conflicts) +2. Tasks with sequential dependencies → same work package (one worker handles the chain) +3. Independent tasks with separate file scopes → separate work packages (parallel execution) +4. A single large task → its own work package + +### File Ownership + +To prevent two workers from modifying the same file simultaneously: + +```rust +pub struct FileOwnershipMap { + locks: HashMap, +} + +impl FileOwnershipMap { + /// Try to acquire ownership of files for an agent. + /// Returns Err if any file is already owned by another agent. + pub fn acquire(&mut self, agent_id: &AgentId, files: &[PathBuf]) -> Result<()>; + + /// Release all files owned by an agent (when worker completes). + pub fn release(&mut self, agent_id: &AgentId); + + /// Check if a file write is permitted for an agent. + pub fn can_write(&self, agent_id: &AgentId, file: &Path) -> bool; +} +``` + +The security layer checks file ownership before allowing writes. Workers declare their file scope when spawned; the orchestrator validates no overlaps before starting the worker. + +### Worker Lifecycle + +```rust +pub struct WorkerHandle { + pub id: AgentId, + pub profile: String, + pub work_package: WorkPackage, + pub state: WorkerState, + pub join_handle: JoinHandle>, + pub cancel_token: CancellationToken, + pub spawned_at: DateTime, + pub last_check_in: DateTime, +} + +pub enum WorkerState { + Spawning, // Tokio task created, agent initializing + Initializing, // Loading context (AGENTS.md, graph nodes, task details) + Working, // Active LLM loop (calling tools, writing code) + Reporting, // Generating completion report + Completed(AgentOutcome), + Failed(String), +} +``` + +Workers are tokio tasks. Each worker: +1. Receives an `AgentContext` with its work package, relevant observations, decisions, and AGENTS.md context +2. Runs the standard agentic loop (LLM call → tool execution → repeat) +3. Reports progress every N turns to the orchestrator via the message bus +4. Signals completion or blocking via the signal tool +5. Gets terminated (cancel token) if it exceeds turn limits or the session ends + +### Error Handling Strategy + +Errors are handled at two levels: the **AgentRuntime** (within a worker) and the **Orchestrator** (across workers). + +**Within a worker (AgentRuntime):** + +- **Transient LLM failures** (network errors, rate limits): Handled by the existing `llm/retry.rs` retry logic with exponential backoff. A single failed API call does not kill the worker. +- **Persistent LLM failures** (`max_consecutive_llm_failures` consecutive failures, default 3): Worker gives up and signals `TaskBlocked` with the error details. The orchestrator marks the task Blocked. +- **Malformed tool calls**: AgentRuntime catches these and sends the error back to the LLM as a tool result (e.g., "invalid tool call: unknown tool 'foo'"). The LLM gets a chance to self-correct. Counts toward a "confusion counter" — after `max_consecutive_tool_failures` consecutive bad calls (default 3), worker signals blocked. +- **Invalid graph operations**: GraphStore validates inputs and returns errors. AgentRuntime feeds the error back to the LLM as a tool result. Normal self-correction applies. +- **Token budget**: AgentRuntime tracks cumulative tokens (input + output) per worker. At `token_budget_warning_pct` (default 80%), a system message is injected: "You are approaching your token budget. Wrap up your current work and signal completion with what you've accomplished." At 100% of `worker_token_budget` (default 200,000), the worker is force-stopped and signals incomplete — the orchestrator treats this as a partial completion, not a failure. + +**Across workers (Orchestrator):** + +- **Task retry semantics**: When a task fails or a worker is blocked, the orchestrator checks the retry count against `max_retries_per_task` (default 2). If retries remain: task status resets to Ready, a fresh worker is spawned with fresh context. The previous attempt's Outcome node (with `success: false`) is visible in the graph, so the new worker can learn from it. +- **Exhausted retries**: Task is marked Failed. The orchestrator creates an Observation node documenting the failure pattern and continues with other tasks. If the failed task blocks downstream work, those tasks become Blocked with reason referencing the failed task. +- **Worker crash** (panic, OOM): The tokio task's `JoinHandle` returns an error. Orchestrator treats this identically to a persistent failure — resets task to Ready if retries remain. + +### Token Accounting + +Each worker tracks cumulative token usage (input + output) during execution. On completion, the total is reported to the orchestrator and stored on the session record. + +**Goal-level budget**: If `max_tokens_per_goal` is set, the orchestrator checks cumulative usage across all workers for that goal before spawning new workers. If the budget is exceeded, the orchestrator pauses and surfaces an approval request — regardless of autonomy level. The user can approve continued spending, adjust the budget, or stop the goal. + +**Visibility**: `rustagent status` shows cumulative tokens for the active goal. Session records include total tokens consumed. No dollar-cost calculation — token counts are provider-agnostic, and pricing changes too frequently to maintain a rate table. Users multiply by their provider's rate. + +### Communication Model + +**Two channels, clearly separated:** + +1. **SQLite (source of truth)**: All durable state — graph nodes, edges, task status, observations, outcomes. Workers write here; the orchestrator reads here. If anything crashes, the DB has the complete picture. +2. **Message bus (real-time signals)**: In-memory, fire-and-forget notifications. Used to wake the orchestrator immediately rather than waiting for it to poll the DB. + +The message bus has **no durability guarantees**. Messages are best-effort, in-memory only. If a message is lost (worker cancelled, bus drops it), no state is corrupted — the orchestrator discovers the same information by querying the DB on its next scheduling pass. This means the system is correct even if the message bus fails completely; it's just slower. + +**Message directions:** +- Worker → Orchestrator: "I'm done", "I'm blocked", "I need a scope change" +- Orchestrator → Worker: "Cancel", "Here's additional context" +- Worker ↔ Worker: Only for review workflows (reviewer sends feedback to coder) +- Workers never message other workers directly outside of review flow + +```rust +pub enum WorkerMessage { + // Worker → Orchestrator + ProgressReport { turn: usize, summary: String }, + TaskCompleted { task_id: String, summary: String }, + TaskBlocked { task_id: String, reason: String }, + NeedsDecision { task_id: String, decision: GraphNode }, + NodeCreated { parent_id: String, node: GraphNode }, + + // Orchestrator → Worker + Cancel { reason: String }, + AdditionalContext { content: String }, + + // Worker ↔ Worker (review flow) + ReviewRequest { work_package_id: String, changed_files: Vec }, + ReviewFeedback { approved: bool, comments: Vec }, +} +``` + +### Recovery + +**Every piece of state is in SQLite.** Recovery is straightforward: + +1. Orchestrator starts → checks for interrupted session +2. Loads goal, node tree, and last session's handoff notes +3. Task nodes that were InProgress when the crash happened → reset to Ready +4. All graph nodes from the interrupted session are preserved +5. Resume from Scheduling state + +Workers that die mid-execution: their tasks are reset to Ready and will be reassigned. Any files they partially modified are detectable via `git diff` and can be rolled back if needed. + +### Keeping Workers On Task + +1. **Focused context**: ContextBuilder gives each worker only relevant info (task details, related observations, applicable AGENTS.md sections, relevant past decisions) +2. **Acceptance criteria**: Workers check their own work against criteria before signaling completion +3. **File scope enforcement**: Security layer prevents writes outside the work package's declared file scope +4. **Turn limits**: Workers have a configurable max turn count; if exceeded, they must report what they accomplished and what's remaining +5. **Check-in intervals**: Every N turns, workers send a progress summary to the orchestrator +6. **Decision logging**: For significant choices, workers must log a decision node before proceeding (encouraged via system prompt, not enforced) + +### Dynamic Adaptation + +The deterministic orchestrator cannot make judgment calls, but it handles scope changes and emergent work through defined mechanisms: + +**File scope expansion**: If a worker needs files outside its declared scope, the security layer blocks the write. The worker signals `NeedsDecision` with the requested files. The orchestrator checks for conflicts with other active workers — if no conflict, it expands the scope and sends `AdditionalContext` to the worker to resume. If there's a conflict, the task is re-queued for after the conflicting worker finishes. + +**Task splitting**: Workers can create subtask nodes under their current task via `create_node`. When the worker completes, the orchestrator discovers the new nodes and schedules them through the normal Scheduling flow. This handles "this task turned out to be three tasks" without requiring re-planning. + +**Re-planning trigger**: When workers create more than `re_plan_threshold` new tasks during a session (configurable, default 5), or when a worker signals `NeedsDecision` about overall approach (not just file scope), the orchestrator spawns a fresh planner worker to reassess the full task tree. The planner sees the current graph state — completed tasks, new subtasks, observations — and can reorganize remaining work. + +### Scaling Considerations + +The orchestrator can handle many workers because: +- Workers are independent tokio tasks with no shared mutable state +- All coordination goes through SQLite (WAL mode for concurrent reads) +- File ownership map is the only shared in-process state (behind a mutex, very fast operations) +- Message bus is fire-and-forget for monitoring (no back-pressure issues) + +Config option `max_concurrent_workers` controls parallelism. Default 4, but can be increased for larger projects with well-separated tasks. + +--- + +## Observability + +### Structured Logging + +The existing `src/logging.rs` (tracing + daily rotation to `~/.local/state/rustagent/logs/`) is carried forward and extended. All log spans are tagged with `agent_id`, `goal_id`, and `task_id` where applicable, so logs can be filtered per worker after the fact. + +### Worker Conversation Persistence + +The full LLM conversation for each worker (all messages, tool calls, and tool results) is stored in a dedicated table. This is the single most useful debugging artifact — it allows replaying exactly what an agent saw and did. + +```sql +CREATE TABLE worker_conversations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + agent_id TEXT NOT NULL, + task_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of task IDs in the work package + messages TEXT NOT NULL, -- JSON array of the full conversation + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + started_at TEXT NOT NULL, + completed_at TEXT +); +``` + +Conversations are written on worker completion (or failure). They are not streamed incrementally — the full conversation is available after the worker finishes. + +### What's Deferred + +- **Metrics collection** (task completion rate, agent utilization, retry rates): Not for v2. Premature until we know what to measure from real usage. +- **Alerting**: Not for v2. Failures are surfaced through WebSocket events and the CLI. + +--- + +## Git Integration + +### No Dedicated Git Tool + +Agents interact with git through the shell tool (bash), not a dedicated `git.rs` tool. This avoids constraining agents to a limited git API — they can run any git command their workflow requires, and users don't hit friction where the dedicated tool doesn't cover their specific needs. Git command access is controlled through the profile's `allowed_commands` patterns (e.g., `"git *"`). + +### Worktree-Based Parallel Isolation + +Parallel workers operate in separate **git worktrees**, providing true filesystem isolation rather than relying solely on the file ownership map as policy enforcement. + +``` +project/ # Main worktree (user's working directory, untouched) +.git/worktrees/ +├── ra-a3f8-wp-001/ # Worktree for work package 001 +├── ra-a3f8-wp-002/ # Worktree for work package 002 +└── ra-a3f8-wp-003/ # Worktree for work package 003 +``` + +**Lifecycle:** + +1. **Goal start**: Orchestrator creates a goal branch (`rustagent/ra-a3f8`) from the current HEAD +2. **Work package spawn**: For each work package, the orchestrator creates a worktree branching from the goal branch (`rustagent/ra-a3f8/wp-001`). The worker's `AgentContext` includes the worktree path — all file operations are rooted there. +3. **Worker execution**: Each worker reads and writes files in its own worktree. Git commands (status, diff, add, commit) operate on the worktree's branch. Workers commit their changes on completion as part of the Reporting state, with a conventional message referencing task IDs. +4. **Work package merge**: On worker completion, the orchestrator merges the work package branch into the goal branch. Since work packages have non-overlapping file scopes (enforced by the file ownership map), merges are always clean. +5. **Goal completion**: All work package branches are merged. The goal branch contains the combined work. The user decides whether to merge into their main branch, push, or take other action. +6. **Cleanup**: Worktrees and work package branches are removed after successful merge into the goal branch. + +### Commit Strategy + +- One commit per work package completion, with message: `rustagent: (ra-a3f8.1, ra-a3f8.2)` +- In Supervised/Gated mode, the PreCommit approval gate fires before the commit is finalized +- In Full autonomy mode, agents can also push if the workflow requires it + +### Why Worktrees + +- **True isolation**: Workers can't accidentally read or write each other's uncommitted changes +- **No merge conflicts between workers**: Non-overlapping file scopes + separate branches = always-clean merges +- **User's working directory is untouched**: The main worktree stays clean — agents work in their own space +- **Native git**: No custom locking or coordination — git handles everything + +--- + +## Autonomy Levels + +### Levels + +Three autonomy levels control how much human oversight the orchestrator requires: + +```rust +// src/config/autonomy.rs + +pub enum AutonomyLevel { + Full, // No gates. Orchestrator runs to completion autonomously. + Supervised, // Gates at key milestones. User reviews and can redirect. + Gated, // Gates at every state transition. User approves each step. +} +``` + +### Approval Gates + +```rust +pub enum ApprovalGate { + PlanReview, // After planner creates task tree, before execution begins + PreCommit, // Before code changes are committed to git + TaskComplete, // Before a task is marked Complete (review the work) + DecisionPoint, // When an agent logs a Decision with multiple options + GoalComplete, // Before the goal is marked Complete (final review) + WorkerSpawn, // Before each new worker is spawned +} +``` + +**Which gates are active per level:** + +| Gate | Full | Supervised | Gated | +|------|------|------------|-------| +| PlanReview | - | Yes | Yes | +| PreCommit | - | Yes | Yes | +| TaskComplete | - | Yes | Yes | +| DecisionPoint | - | - | Yes | +| GoalComplete | - | Yes | Yes | +| WorkerSpawn | - | - | Yes | + +### Orchestrator Integration + +When the orchestrator hits an active gate: + +1. Emits an `ApprovalRequest` event (includes gate type, context summary, proposed action) +2. Pauses the relevant operation (but other workers on unrelated tasks continue) +3. Waits for a response: `Approve`, `Reject(reason)`, or `Modify(instructions)` + +- **Approve**: Orchestrator proceeds as planned +- **Reject**: Operation is cancelled. For PlanReview, planner is re-spawned with rejection feedback. For TaskComplete, task returns to InProgress. For PreCommit, changes are kept but not committed. +- **Modify**: Orchestrator incorporates the instructions. For PlanReview, planner is re-spawned with modification guidance. For DecisionPoint, the user's choice is recorded as the chosen option. + +### How Approval Requests Reach the User + +- **CLI mode**: Orchestrator blocks and prints the approval request to stdout. User responds interactively. +- **Daemon mode**: Approval request is emitted as a WebSocket event and stored in a pending approvals table. Web UI shows a notification. CLI can also poll: `rustagent approvals` lists pending, `rustagent approve ` / `rustagent reject --reason "..."` responds. + +### Scoping + +- **Default**: Set per-project in project config (`.rustagent/config.toml` or at registration time) +- **Override**: Per-goal at run time: `rustagent run --autonomy supervised "Add auth"` +- **Fallback**: If no project default is set, defaults to `Supervised` + +--- + +## Module Structure (Updated) + +``` +src/ +├── main.rs # CLI: project, run, plan, status, tasks, sessions, decisions, graph (import/export/diff), search, daemon +├── lib.rs +├── config/ +│ ├── mod.rs # Config loading + env var expansion +│ ├── agents.rs # Agent profile parsing +│ └── autonomy.rs # AutonomyLevel, ApprovalGate types +├── project.rs # Project type, registration, cwd resolution +├── logging.rs # Carry forward +├── llm/ # Carry forward entirely +│ ├── mod.rs, anthropic.rs, openai.rs, ollama.rs, mock.rs +│ ├── error.rs, retry.rs, factory.rs +├── agent/ +│ ├── mod.rs # Agent trait, AgentId, AgentContext, AgentOutcome, WorkerState +│ ├── profile.rs # AgentProfile (role, prompt, tools, LLM config, security scope) +│ ├── runtime.rs # AgentRuntime - generic agentic loop (the worker's brain) +│ ├── orchestrator.rs # Deterministic state machine: scheduling, monitoring, recovery +│ ├── work_package.rs # WorkPackage grouping + file ownership map +│ └── builtin_profiles.rs # Default planner/coder/reviewer/tester/researcher +├── message/ +│ ├── mod.rs # Envelope, AgentMessage, MessageBus trait +│ ├── bus.rs # TokioMessageBus +│ └── envelope.rs # Envelope type +├── graph/ +│ ├── mod.rs # GraphNode, GraphEdge, NodeType, EdgeType, NodeStatus, Priority +│ ├── store.rs # GraphStore trait + SQLite impl +│ ├── query.rs # Query builders (node queries, edge traversal) +│ ├── dependency.rs # Dependency resolution + ready surfacing for task nodes +│ ├── session.rs # Session management + handoff notes +│ ├── decay.rs # Node compaction for context injection (age-based detail levels) +│ ├── export.rs # Export decisions to markdown ADR files +│ └── interchange.rs # TOML import/export for collaboration (graph ↔ .rustagent/graph/) +├── context/ +│ ├── mod.rs # ContextBuilder (compact structured format + on-demand expansion) +│ └── agents_md.rs # AGENTS.md parser + resolver (per https://agents.md/ spec) +├── security/ +│ ├── mod.rs # SecurityValidator (carry forward) +│ ├── permission.rs # PermissionHandler (carry forward) +│ └── scope.rs # Per-agent SecurityScope +├── tools/ +│ ├── mod.rs # Tool trait + ToolRegistry (carry forward) +│ ├── factory.rs # Extended factory +│ ├── file.rs, shell.rs, signal.rs, permission_check.rs # Carry forward +│ ├── search.rs # Code search +│ ├── graph_tools.rs # Unified: create_node, update_node, add_edge, claim_task, log_decision, choose_option, record_outcome, query_nodes, search_nodes +│ └── agent_tools.rs # spawn_sub_agent, send_message, query_agent_status +├── daemon/ +│ ├── mod.rs # Daemon startup, shutdown, PID management +│ ├── server.rs # Axum HTTP server setup + routes +│ ├── api/ +│ │ ├── mod.rs # API route handlers +│ │ ├── projects.rs # Project CRUD endpoints +│ │ ├── graph.rs # Node/edge CRUD, task views, decision views +│ │ ├── search.rs # Full-text search endpoint +│ │ └── agents.rs # Agent status endpoints +│ └── ws.rs # WebSocket handler + event broadcasting +├── db/ +│ ├── mod.rs # Database init + connection pool +│ └── migrations.rs # Schema (projects, nodes, edges, sessions, nodes_fts, worker_conversations) +``` + +## Agent Profiles + +### AgentProfile Type + +```rust +// src/agent/profile.rs + +pub struct AgentProfile { + pub name: String, // e.g., "coder", "planner", "my-rust-coder" + pub extends: Option, // Built-in to inherit from + pub role: String, // One-line role description + pub system_prompt: String, // Full system prompt (or template with {{variables}}) + pub allowed_tools: Vec, // Tool group names: "file", "shell", "graph", "signal", "search", "agent" + pub security: SecurityScope, + pub llm: ProfileLlmConfig, + pub turn_limit: Option, // Override OrchestratorConfig.worker_turn_limit + pub token_budget: Option, // Override OrchestratorConfig.worker_token_budget +} + +pub struct SecurityScope { + pub allowed_paths: Vec, // Glob patterns for file access (e.g., "src/**", "tests/**") + pub denied_paths: Vec, // Explicit denials (e.g., ".env", "**/*.key") + pub allowed_commands: Vec, // Shell command patterns (e.g., "cargo *", "git diff *") + pub read_only: bool, // If true, file writes are blocked regardless of path + pub can_create_files: bool, // Can create new files (vs only editing existing) + pub network_access: bool, // Can make outbound network requests via tools +} + +pub struct ProfileLlmConfig { + pub model: Option, // Override default model (e.g., use cheaper model for planning) + pub temperature: Option, + pub max_tokens: Option, // Per-response max tokens +} +``` + +### Built-in Profiles + +Five built-in profiles cover the standard workflow. Custom profiles can extend these. + +| Profile | Role | Tools | File Access | Key Behavior | +|---------|------|-------|-------------|--------------| +| **planner** | Breaks goals into tasks and decisions | graph, signal | Read-only | Creates task tree, decision nodes, dependency edges. No code changes. | +| **coder** | Implements tasks by writing code | file, shell, graph, signal | Write (scoped to work package) | Executes tasks, logs decisions for non-trivial choices, records outcomes. Git via shell. | +| **reviewer** | Reviews completed work for correctness | file, shell, graph, signal | Read-only | Reads code, runs tests/lints, creates Observation nodes for issues found. | +| **tester** | Writes and runs tests | file, shell, graph, signal | Write (scoped to test dirs) | Writes tests against acceptance criteria, runs them, reports coverage. | +| **researcher** | Gathers information and context | file, shell, search, graph, signal | Read-only | Reads code, searches, stores findings as observations. | + +**System prompt structure** for all built-in profiles follows a common template: + +``` +You are a {role} agent working on project "{project_name}". + +## Your Task +{task_description} + +## Acceptance Criteria +{acceptance_criteria} + +## Context +{relevant_decisions} +{agents_md_content} + +## Rules +- {profile-specific rules} +- When you make a non-trivial choice between alternatives, log a decision using log_decision. +- When you discover something noteworthy, record it using record_observation. +- Signal completion or blocking using the signal tool. Do not simply stop. +``` + +Profile-specific rules (examples): +- **planner**: "Break work into tasks that can be completed independently. Keep tasks small enough for a single focused session. Specify acceptance criteria for every task." +- **coder**: "Check your work against the acceptance criteria before signaling completion. Only modify files within your declared scope. Commit logical units of work." +- **reviewer**: "Do not modify files. Report issues as Observation nodes. Approve or reject via the signal tool with specific feedback." + +### Custom Profiles (TOML) + +Custom profiles live in two locations, both using one TOML file per profile: + +``` +# Project-level (version-controlled, shared with team) +.rustagent/ +├── profiles/ +│ ├── rust-coder.toml +│ └── docs-writer.toml +├── config.toml # Project config (autonomy level, defaults) +├── graph/ # Graph export (already specified) +│ └── ... +└── sessions/ + └── ... + +# User-level (personal preferences, not version-controlled) +~/.config/rustagent/profiles/ +├── my-coder.toml +└── my-planner.toml +``` + +Example project-level profile: + +```toml +# .rustagent/profiles/rust-coder.toml + +name = "rust-coder" +extends = "coder" # Inherits all coder defaults +role = "Rust implementation specialist" +system_prompt = """ +You are a senior Rust developer. Follow these project conventions: +- Use thiserror for error types, anyhow in binaries +- Prefer &str over String in function parameters +- Write doc comments for all public items +""" + +allowed_tools = ["file", "shell", "graph", "signal"] + +[security] +allowed_paths = ["src/**", "tests/**", "Cargo.toml", "Cargo.lock"] +denied_paths = [".env", "**/*.key", "**/*.pem"] +allowed_commands = ["cargo *", "rustfmt *", "git diff *", "git status"] +read_only = false +can_create_files = true +network_access = false + +[llm] +model = "claude-sonnet-4-20250514" +temperature = 0.3 +``` + +### Inheritance Rules + +When a profile specifies `extends`: + +1. **All fields start as copies of the parent profile** +2. **Scalar fields** (role, system_prompt, read_only, etc.): child value replaces parent if specified +3. **List fields** (allowed_tools, allowed_paths, allowed_commands): child value **replaces** parent entirely (not merged). This prevents accidentally widening access by inheriting a broad parent and adding more. +4. **system_prompt**: If the child specifies a system_prompt, it is **appended** to the parent's prompt (separated by a newline section header `## Project-Specific Instructions`). This preserves the structural template while allowing customization. To fully replace, set `extends` to null. +5. **Unset optional fields** (turn_limit, token_budget, llm overrides): fall through to parent, then to orchestrator defaults. + +### Profile Resolution + +When the orchestrator spawns a worker, profiles are resolved in priority order: + +1. **Project-level**: `.rustagent/profiles/*.toml` in the project root (checked into git) +2. **User-level**: `~/.config/rustagent/profiles/*.toml` (personal overrides) +3. **Built-in profiles**: Compiled-in defaults (planner, coder, reviewer, tester, researcher) + +First match wins. If the resolved profile has `extends`, the parent is resolved through the same chain and inheritance rules are applied. + +**Validation** (at resolution time): +- `allowed_tools` only references known tool groups +- `allowed_paths` doesn't escape the project root +- `extends` doesn't create cycles +- Profile names are unique within each level (duplicate across levels is fine — higher priority wins) + +--- + +## Context Assembly + +### ContextBuilder + +The `ContextBuilder` assembles an `AgentContext` for each worker at spawn time. It uses two strategies to minimize token consumption while keeping all context accessible: + +- **Compact structured format** for essential context (task details, decisions, handoff notes) — always included inline +- **Summary + on-demand expansion** for reference context (observations, AGENTS.md) — titles/one-liners inline, full detail available via tools + +### Context Template + +The assembled context is injected as the system prompt. Target: **3-4K tokens** for priorities 1-7, leaving the rest of the context window for the agentic loop. + +``` +## Role +{profile.role} + +## Task +[TASK] {task.id} | {task.title} | priority={task.priority} +[CRITERIA] {acceptance_criteria, semicolon-separated} +[DEP:DONE] {completed_dependency_id} → {title} (completed) +[DEP:PENDING] {pending_dependency_id} → {title} (status) + +## Previous Attempt (if retry) +[PREV_ATTEMPT] {outcome.description} + +## Session Continuity +[HANDOFF] {handoff_notes, compressed to key facts} + +## Active Decisions +[DECISION] {id} | {title} | chosen: {chosen_option} | reason: {rationale} + +## Relevant Observations (use query_nodes(id) for full detail) +- {node_id}: {one-line summary} +- {node_id}: {one-line summary} + +## Project Conventions (use read_agents_md(path) for full text) +- {path}: {section_title} ({rule_count} rules) +- {path}: {section_title} ({rule_count} rules) + +## Rules +{profile.system_prompt rules section} +``` + +### Token Budget Allocation + +| Priority | Content | Strategy | Budget | +|----------|---------|----------|--------| +| 1 (required) | System prompt (profile template + rules) | Compact inline | ~800 tokens | +| 2 (required) | Task details + acceptance criteria | Compact structured | ~400 tokens | +| 3 (high) | Previous attempt outcomes (if retry) | Compact structured | ~300 tokens | +| 4 (high) | Handoff notes from last session | Compact structured | ~300 tokens | +| 5 (medium) | Active decisions relevant to this task | Compact structured | ~400 tokens | +| 6 (medium) | Relevant observations | Summary only; `query_nodes(id)` for detail | ~300 tokens | +| 7 (medium) | AGENTS.md sections | Summary only; `read_agents_md(path)` for detail | ~200 tokens | +| 8 (remainder) | Conversation history (agentic loop) | Grows during execution | Everything else | + +**Overflow handling**: If the total for priorities 1-7 exceeds the budget (e.g., many decisions, many observations), items are trimmed from the bottom of each section (least relevant first, determined by recency and graph distance from the current task). + +### On-Demand Expansion Tools + +Workers can pull full detail during execution using existing tools: + +- `query_nodes(node_id)` → Returns full node detail with edges (in graph_tools) +- `search_nodes(query)` → Full-text search over all node titles/descriptions (in graph_tools) +- `read_agents_md(path)` → Returns full AGENTS.md content for a directory path (in context module) + +This means workers aren't missing anything — they just have to ask for it. The initial context tells them *what exists* so they know what to ask for. + +### Static Context + +Context is built once at worker spawn and does not change during execution. If a worker needs fresh information mid-execution (e.g., checking if a dependency was completed by another worker), it uses `query_nodes` to read current graph state. This avoids the confusion of context shifting under the agent. + +### AGENTS.md Support + +Rustagent follows the [AGENTS.md specification](https://agents.md/) — a simple, open format for guiding coding agents adopted by 60,000+ open-source projects. AGENTS.md files are free-form Markdown with no required sections or special syntax. They serve as project-specific instructions for agents (build commands, code style, testing conventions, security rules, etc.). + +**Scoping**: Per the spec, "the closest AGENTS.md to the edited file wins." Rustagent resolves this by walking the directory hierarchy toward the worker's file scope: + +1. Start at project root, collect `AGENTS.md` if present +2. Walk toward each path in the work package's file scope +3. Collect `AGENTS.md` at each intermediate directory +4. The closest file to the target takes precedence; root-level `AGENTS.md` is always included as baseline context + +For a worker scoped to `src/auth/**`: +- `AGENTS.md` (project root) → general project rules +- `src/AGENTS.md` → source conventions (if exists) +- `src/auth/AGENTS.md` → auth-specific rules (if exists, takes precedence for auth-related guidance) + +**Context injection**: Only file paths and top-level heading summaries are included in the initial compact context. The agent uses `read_agents_md("src/auth")` to retrieve full content on demand. + +**Implementation note**: The parser (`src/context/agents_md.rs`) should handle the format as plain Markdown text — no semantic parsing of sections beyond extracting headings for the summary. Explicit user prompts (the task description and acceptance criteria) override AGENTS.md instructions per the spec's conventions. + +### Node Decay Thresholds + +Completed graph nodes are compacted based on age for context injection. These thresholds are configurable in `.rustagent/config.toml`: + +| Age | Detail Level | Example | +|-----|-------------|---------| +| Recent (< 7 days) | Full: description, criteria, outcomes | "Implemented JWT validation using jsonwebtoken crate. Tokens validated against RS256 keys from JWKS endpoint." | +| Older (7-30 days) | Summary: title, status, key outcome | "JWT validation — completed, success" | +| Ancient (> 30 days) | Minimal: title, status | "JWT validation — completed" | + +```toml +# .rustagent/config.toml +[context.decay] +recent_days = 7 +older_days = 30 +# Nodes older than older_days get minimal detail +``` + +Decay applies when nodes are included in context assembly. Full detail is always available via `query_nodes` regardless of age. + +--- + +## Core Traits + +### Agent +```rust +#[async_trait] +pub trait Agent: Send + Sync { + fn id(&self) -> &AgentId; + fn profile(&self) -> &AgentProfile; + async fn run(&self, ctx: AgentContext) -> Result; + fn cancel(&self); +} +``` + +### GraphStore +```rust +#[async_trait] +pub trait GraphStore: Send + Sync { + // Node operations + async fn create_node(&self, node: &GraphNode) -> Result<()>; + async fn update_node(&self, node: &GraphNode) -> Result<()>; + async fn get_node(&self, id: &str) -> Result>; + async fn query_nodes(&self, query: NodeQuery) -> Result>; + + // Task-specific convenience methods + async fn claim_task(&self, node_id: &str, agent_id: &AgentId) -> Result; // Atomic claim + async fn get_ready_tasks(&self, goal_id: &str) -> Result>; + async fn get_next_task(&self, goal_id: &str) -> Result>; // Priority-based + + // Edge operations + async fn add_edge(&self, edge: &GraphEdge) -> Result<()>; + async fn remove_edge(&self, edge_id: &str) -> Result<()>; + async fn get_edges(&self, node_id: &str, direction: EdgeDirection) -> Result>; + + // Graph queries + async fn get_children(&self, node_id: &str) -> Result>; + async fn get_subtree(&self, node_id: &str) -> Result>; + async fn get_active_decisions(&self, project_id: &str) -> Result>; // Now mode + async fn get_full_graph(&self, goal_id: &str) -> Result; // History mode + async fn search_nodes(&self, query: &str, project_id: Option<&str>, limit: usize) -> Result>; // FTS5 full-text search + + // Session management + async fn create_session(&self, session: &Session) -> Result<()>; + async fn end_session(&self, session_id: &str, handoff_notes: &str) -> Result<()>; + async fn get_latest_session(&self, goal_id: &str) -> Result>; +} + +pub enum EdgeDirection { + Outgoing, // Edges where this node is from_node + Incoming, // Edges where this node is to_node + Both, +} +``` + +### MessageBus +```rust +#[async_trait] +pub trait MessageBus: Send + Sync { + async fn send(&self, to: &AgentId, msg: WorkerMessage) -> Result<()>; + async fn broadcast(&self, msg: WorkerMessage) -> Result<()>; + fn subscribe(&self, agent_id: &AgentId) -> mpsc::Receiver; +} +``` + +See [Communication Model](#communication-model) for `WorkerMessage` variants and delivery semantics. + +--- + +## Database Schema + +### Schema Versioning and Migrations + +```sql +-- Schema version tracking (single row) +CREATE TABLE schema_version ( + version INTEGER NOT NULL, + migrated_at TEXT NOT NULL +); +``` + +At startup, the binary checks `schema_version.version` against its expected version: +- **Match**: Proceed normally +- **DB is older**: Run sequential migrations forward (e.g., `migrate_001_to_002`, `migrate_002_to_003`). Each migration is a SQL function in `src/db/migrations.rs`. No external migration tooling — hand-rolled, simple, single-binary friendly. +- **DB is newer**: Error with "your database was created by a newer version of rustagent, please upgrade" +- **No table**: Fresh database, run full schema creation and set version to current + +The binary supports reading schema version N-1 (one version back) for graceful upgrades. Older than that produces a clear upgrade error. + +### TOML Export Format Versioning + +The `[meta]` section already includes `version = 1`. On format changes: +- Bump the version number +- Import checks the version and applies format-specific parsing +- Old format files produce: "this file uses format v1, run `rustagent graph upgrade` to convert to v2" +- The binary supports reading format version N-1 + +### Tables + +```sql +-- Projects +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + registered_at TEXT NOT NULL, + config_overrides TEXT, + metadata TEXT NOT NULL DEFAULT '{}' +); + +-- Unified work graph: nodes +-- All entity types (goal, task, decision, option, outcome, observation, revisit) +-- live in one table. Type-specific data goes in metadata (JSON). +CREATE TABLE nodes ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + node_type TEXT NOT NULL, -- goal, task, decision, option, outcome, observation, revisit + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + priority TEXT, -- goals and tasks + assigned_to TEXT, -- tasks + created_by TEXT, + labels TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + blocked_reason TEXT, + metadata TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX idx_nodes_project ON nodes(project_id); +CREATE INDEX idx_nodes_type ON nodes(node_type); +CREATE INDEX idx_nodes_status ON nodes(status); + +-- Unified work graph: edges +-- All relationships (contains, depends_on, leads_to, chosen, rejected, etc.) +CREATE TABLE edges ( + id TEXT PRIMARY KEY, + edge_type TEXT NOT NULL, -- contains, depends_on, leads_to, chosen, rejected, supersedes, informs + from_node TEXT NOT NULL REFERENCES nodes(id), + to_node TEXT NOT NULL REFERENCES nodes(id), + label TEXT, -- e.g., rejection reason + created_at TEXT NOT NULL +); + +CREATE INDEX idx_edges_from ON edges(from_node); +CREATE INDEX idx_edges_to ON edges(to_node); +CREATE INDEX idx_edges_type ON edges(edge_type); + +-- Sessions (temporal, not part of the graph) +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + goal_id TEXT NOT NULL REFERENCES nodes(id), + started_at TEXT NOT NULL, + ended_at TEXT, + handoff_notes TEXT, + agent_ids TEXT NOT NULL DEFAULT '[]', + summary TEXT +); + +-- Full-text search over graph nodes (titles + descriptions) +CREATE VIRTUAL TABLE nodes_fts USING fts5( + title, + description, + content='nodes', + content_rowid='rowid' +); + +-- Triggers to keep FTS index in sync with nodes table +CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid, title, description) + VALUES (new.rowid, new.title, new.description); +END; +CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, description) + VALUES ('delete', old.rowid, old.title, old.description); +END; +CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, description) + VALUES ('delete', old.rowid, old.title, old.description); + INSERT INTO nodes_fts(rowid, title, description) + VALUES (new.rowid, new.title, new.description); +END; + +-- Worker conversation persistence (see Observability section) +CREATE TABLE worker_conversations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + agent_id TEXT NOT NULL, + task_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of task IDs in the work package + messages TEXT NOT NULL, -- JSON array of the full conversation + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + started_at TEXT NOT NULL, + completed_at TEXT +); +``` + +--- + +## Work Graph Tools for Agents + +Agents interact with the unified work graph through these tools. Low-level tools operate on raw nodes/edges; high-level tools provide workflow shortcuts. + +### Low-Level (generic graph operations) + +``` +create_node - Create any node in the graph + Params: { node_type, title, description, parent_id?, priority?, metadata? } + +update_node - Update a node's status or metadata + Params: { node_id, status?, title?, description?, metadata? } + +add_edge - Create a relationship between two nodes + Params: { edge_type, from_node, to_node, label? } + +query_nodes - Search nodes by type, status, project, text + Params: { node_type?, status?, project_id?, query?, parent_id? } + +search_nodes - Full-text search over node titles and descriptions (FTS5) + Params: { query, project_id?, node_type?, limit? } +``` + +### High-Level (workflow shortcuts) + +``` +claim_task - Atomically claim a Ready task node + Params: { node_id } + +log_decision - Shortcut: create Decision + Option nodes in one call + Params: { title, description, options: [{ title, description, pros?, cons? }], parent_id? } + +choose_option - Mark an Option as chosen, reject others, update Decision status + Params: { decision_id, option_id, rationale } + +record_outcome - Create Outcome node linked to a Task or Option + Params: { parent_id, title, description, success: bool } + +record_observation - Create Observation node linked to any node + Params: { title, description, related_node_id? } + +revisit - Create Revisit from bad Outcome, optionally start new Decision + Params: { outcome_id, reason, new_decision_title? } +``` + +--- + +## Implementation Phases + +### Phase 1a: Database + Projects +1. Set up `src/db/` - Central SQLite at `~/.local/share/rustagent/rustagent.db`, WAL mode, `BEGIN IMMEDIATE` discipline +2. Implement `src/db/migrations.rs` - Full schema (projects, nodes, edges, sessions, nodes_fts, worker_conversations) +3. Implement `src/project.rs` - Project type, registration, resolution from cwd +4. Wire up basic CLI - `project` subcommand (add/list/remove/show) + +**Verification:** `rustagent project add test-proj .` registers a project. `rustagent project list` shows it. `rustagent project show test-proj` returns details. Database created at XDG data dir with correct schema. + +### Phase 1b: Graph Model + Node Lifecycle +1. Implement `src/graph/mod.rs` - GraphNode, GraphEdge, NodeType, EdgeType, NodeStatus, Priority types +2. Implement `src/graph/store.rs` - GraphStore trait + SQLite implementation (CRUD for nodes and edges) +3. Implement `src/graph/query.rs` - Query builders (node queries, edge traversal, FTS5 search) +4. Implement `src/graph/dependency.rs` - Dependency resolution, ready surfacing, next task recommendation +5. Build `src/tools/graph_tools.rs` - All graph tools (create_node, update_node, add_edge, claim_task, log_decision, choose_option, record_outcome, record_observation, revisit, query_nodes, search_nodes) +6. Wire up CLI - `tasks` (list/ready/next/tree), `decisions` (list/now/history/show), `status` + +**Verification:** Create nodes and edges via tests. Dependency resolution correctly surfaces ready tasks. `claim_task` is atomic (concurrent claims test). FTS5 search finds nodes by content. CLI commands display graph state. + +### Phase 1c: Sessions + Export + Interchange +1. Implement `src/graph/session.rs` - Session management, template-based handoff notes generation +2. Implement `src/graph/export.rs` - ADR export (decision nodes → markdown files) +3. Implement `src/graph/interchange.rs` - TOML import/export/diff for `.rustagent/graph/` +4. Implement `src/graph/decay.rs` - Node compaction by age for context injection +5. Wire up CLI - `sessions` (list/latest), `decisions export`, `graph` (export/import/diff) + +**Verification:** Session creates and ends with accurate handoff notes. `rustagent decisions export` generates readable ADR files. `rustagent graph export` produces TOML; `rustagent graph import` round-trips cleanly. Decay returns appropriate detail levels by node age. + +### Phase 1d: Agent Runtime + Single-Agent Execution +1. Cherry-pick `src/llm/`, `src/security/`, `src/tools/` with interface adjustments +2. Define `src/agent/mod.rs` - Agent trait, AgentId, AgentContext, AgentOutcome +3. Build `src/agent/profile.rs` - AgentProfile type, built-in profile definitions, profile resolution (project → user → built-in) +4. Build `src/agent/runtime.rs` - Refactor Ralph loop into generic AgentRuntime with error handling (confusion counter, token budget tracking) +5. Extend `src/config/` - Agent profiles, autonomy mode +6. Update AgentRuntime to encourage decision logging in system prompts +7. Wire up CLI - `run` (single-agent mode), `search` + +**Verification:** `rustagent run --project test-proj "goal"` executes with a single agent. Agent creates tasks, makes decisions, records observations. Profile resolution works (project `.rustagent/profiles/` overrides built-ins). Token budget warning triggers at configured threshold. + +### Phase 2: Multi-Agent Orchestration +1. Build `src/agent/work_package.rs` - WorkPackage type, file ownership map, grouping logic +2. Build `src/agent/orchestrator.rs` - Full state machine (Startup → Loading → Planning → Scheduling → Monitoring → Reviewing → Completing), recovery logic +3. Build `src/message/` - WorkerMessage types, TokioMessageBus (broadcast + per-agent mpsc) +4. Build `src/agent/builtin_profiles.rs` - System prompts for planner, coder, reviewer, tester, researcher +5. Build `src/tools/agent_tools.rs` - spawn_sub_agent, send_message, query_agent_status +6. Update orchestrator ↔ runtime integration: check-in intervals, turn limits, file scope enforcement +7. Update CLI - `run` uses orchestrator, `status` shows active workers + task progress + +**Verification:** `rustagent run "goal"` → orchestrator spawns planner → planner creates tasks → orchestrator groups into work packages → spawns concurrent coder workers for independent packages → workers complete → orchestrator marks tasks done. Recovery test: kill mid-execution, restart, verify it resumes. + +### Phase 3: Daemon + HTTP API +1. Build `src/daemon/mod.rs` - Daemon lifecycle (start, stop, PID file) +2. Build `src/daemon/server.rs` - Axum server setup, route mounting, static file serving +3. Build `src/daemon/api/` - All REST endpoints (projects, graph nodes/edges, search, agents) +4. Build `src/daemon/ws.rs` - WebSocket handler + broadcast integration with message bus +5. Update CLI to detect daemon and route commands through API when available +6. Add `daemon` subcommand to main.rs (start, stop, status, logs) + +**Verification:** `rustagent daemon start` starts server. `curl localhost:7400/api/projects` returns data. WebSocket connects and receives events during `rustagent run`. + +### Phase 4: Web UI +1. Initialize `web/` with Bun + Vite + Svelte 5 + TypeScript +2. Build API client and WebSocket connection handler +3. Build Dashboard view (overview across projects) +4. Build Task Tree view (projection of work graph: task nodes with hierarchy) +5. Build Decision Graph view (projection of work graph: decision/option/outcome nodes, now/history toggle) +6. Build Agent Monitor view (real-time agent activity feed) +7. Build Graph Search view (FTS5 search + filter) +8. Build Session History view +9. Configure Vite proxy for development, static serving for production + +**Verification:** `bun run dev` in `web/` shows dashboard. Creating a goal via API updates the UI in real-time via WebSocket. + +### Phase 5: AGENTS.md + Context + Polish +1. Build `src/context/agents_md.rs` - Parse AGENTS.md, closest-to-file resolution +2. Build `src/context/mod.rs` - ContextBuilder combining all context sources +3. Build `src/config/autonomy.rs` - Autonomy levels + approval gates +4. Build `src/security/scope.rs` - Per-agent security boundaries +5. Build `src/tools/search.rs` +6. Agent error recovery, task reassignment + +**Verification:** AGENTS.md content appears in agent context. Gated mode prompts at configured gates. + +--- + +## Testing Strategy + +### Methodology + +All implementation follows **test-driven development (TDD)** with strict red-green-refactor: + +1. **Red**: Write a failing test that defines the expected behavior +2. **Green**: Write the minimum code to make the test pass +3. **Refactor**: Clean up while keeping tests green + +Tests are written *before* implementation, not after. This applies to all phases — graph operations, orchestrator logic, API endpoints, CLI commands, and tools. No feature is considered complete without tests that were written first and observed to fail. + +### Test Layers + +- **Unit tests**: Pure logic in isolation — graph node lifecycle state machine, dependency resolution, ID generation, TOML serialization round-trips, FTS5 queries, profile inheritance, context assembly, node decay. Use `llm/mock.rs` (carried forward from v1) for LLM interactions. +- **Integration tests**: Components working together — single-agent pipeline (goal → plan → execute → complete), multi-agent orchestration with 2-3 workers, daemon HTTP endpoints (using axum's built-in test utilities), WebSocket event delivery, graph import/export with conflict resolution. +- **Concurrency tests**: Race conditions that matter — multiple workers claiming the same task (exactly one succeeds), concurrent child node creation under one parent (no ID collisions), orchestrator handling simultaneous worker completions. + +### What We Don't Test in CI + +No real LLM calls in automated tests. Too slow, too expensive, too flaky. Integration tests use the mock LLM client. Real LLM testing is manual, run against a live provider before releases. + +--- + +## New Dependencies + +### Rust (Cargo.toml) +```toml +rusqlite = { version = "0.32", features = ["bundled"] } +tokio-rusqlite = "0.6" +tokio-util = "0.7" # CancellationToken +pulldown-cmark = "0.12" # AGENTS.md parsing +walkdir = "2.5" # Directory traversal +glob = "0.3" # File pattern matching +toml = "0.8" # TOML serialization for graph interchange +blake3 = "1" # Content hashing for export change detection +axum = { version = "0.8", features = ["ws"] } # HTTP server + WebSocket +tower = "0.5" # Middleware +tower-http = { version = "0.6", features = ["cors", "fs"] } # CORS + static files +rust-embed = { version = "8", features = ["axum"], optional = true } # Static asset embedding (bundle-ui feature) +``` + +### Web UI (web/package.json) +```json +{ + "dependencies": { + "cytoscape": "^3.x" + }, + "devDependencies": { + "svelte": "^5.x", + "@sveltejs/vite-plugin-svelte": "^5.x", + "typescript": "^5.x", + "vite": "^6.x" + } +} +``` + +## Files to Cherry-Pick from Current Codebase +- `src/llm/*` - All LLM client code +- `src/security/mod.rs` - SecurityValidator +- `src/security/permission.rs` - PermissionHandler trait + impls +- `src/tools/mod.rs` - Tool trait + ToolRegistry +- `src/tools/file.rs` - File operation tools +- `src/tools/shell.rs` - RunCommandTool +- `src/tools/signal.rs` - SignalTool +- `src/tools/permission_check.rs` - FilePermissionChecker +- `src/logging.rs` - Tracing setup diff --git a/hk.pkl b/hk.pkl new file mode 100644 index 0000000..b2a9f4d --- /dev/null +++ b/hk.pkl @@ -0,0 +1,45 @@ +amends "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Config.pkl" +import "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Builtins.pkl" + +local linters = new Mapping { + // uses builtin prettier linter config + ["prettier"] = Builtins.prettier + + // define a custom linter + ["pkl"] { + glob = "*.pkl" + check = "pkl eval {{files}} >/dev/null" + } +} + +hooks { + ["pre-commit"] { + fix = true // automatically modify files with available linter fixes + stash = "git" // stashes unstaged changes while running fix steps + steps { + // "prelint" here is simply a name to define the step + ["prelint"] { + // if a step has a "check" script it will execute that + check = "mise run prelint" + exclusive = true // ensures that the step runs in isolation + } + ...linters // add all linters defined above + ["postlint"] { + check = "mise run postlint" + exclusive = true + } + } + } + // instead of pre-commit, you can instead define pre-push hooks + ["pre-push"] { + steps = linters + } + // "fix" and "check" are special steps for `hk fix` and `hk check` commands + ["fix"] { + fix = true + steps = linters + } + ["check"] { + steps = linters + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs index 3ba99fb..33ec1cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,4 +6,3 @@ pub mod ralph; pub mod security; pub mod spec; pub mod tools; -pub mod tui; diff --git a/src/main.rs b/src/main.rs index 07c7f41..5c2928b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use rustagent::{config, logging, planning, ralph}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, CommandFactory}; use std::path::PathBuf; #[derive(Parser)] @@ -33,8 +33,6 @@ enum Commands { #[arg(long)] max_iterations: Option, }, - /// Launch interactive TUI - Tui, } /// Find config file in standard locations @@ -72,8 +70,11 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - // Default to TUI if no command specified - let command = cli.command.unwrap_or(Commands::Tui); + // If no command specified, print help + let Some(command) = cli.command else { + Cli::command().print_help()?; + return Ok(()); + }; match command { Commands::Init { spec_dir } => { @@ -129,23 +130,6 @@ async fn main() -> anyhow::Result<()> { let ralph = ralph::RalphLoop::new(config, spec_file.clone(), max_iterations)?; ralph.run().await?; } - Commands::Tui => { - let config_path = find_config_path()?; - let config = config::Config::load(&config_path)?; - let spec_dir = config.rustagent.spec_dir.clone(); - - use rustagent::tui::{self, agent_channel}; - - let mut terminal = tui::setup_terminal()?; - let (tx, mut rx) = agent_channel(); - let mut app = tui::App::new(&spec_dir, tx, Some(config)); - - let result = tui::run(&mut terminal, &mut app, &mut rx).await; - - tui::restore_terminal(&mut terminal)?; - - result?; - } } Ok(()) diff --git a/src/planning/mod.rs b/src/planning/mod.rs index 9464128..078c8f3 100644 --- a/src/planning/mod.rs +++ b/src/planning/mod.rs @@ -4,7 +4,6 @@ use crate::llm::{LlmClient, Message, ResponseContent}; use crate::security::{SecurityValidator, permission::CliPermissionHandler}; use crate::tools::ToolRegistry; use crate::tools::factory::create_default_registry; -use crate::tui::messages::{AgentMessage, AgentSender}; use std::io::{self, Write}; use std::sync::Arc; @@ -93,90 +92,6 @@ impl PlanningAgent { Ok(()) } - /// Run planning with a message sender for TUI integration - pub async fn run_with_sender( - &mut self, - tx: AgentSender, - initial_message: String, - ) -> anyhow::Result<()> { - tx.send(AgentMessage::PlanningStarted).await?; - - self.conversation.push(Message::user(&initial_message)); - - loop { - let tools = self.registry.definitions(); - let response = self.client.chat(self.conversation.clone(), &tools).await?; - - match response.content { - ResponseContent::Text(text) => { - self.conversation.push(Message::assistant(text.clone())); - tx.send(AgentMessage::PlanningResponse(text)).await?; - - // Check for end of turn - if matches!(response.stop_reason.as_deref(), Some("end_turn")) { - break; - } - break; - } - ResponseContent::ToolCalls(tool_calls) => { - for tool_call in &tool_calls { - tx.send(AgentMessage::PlanningToolCall { - name: tool_call.name.clone(), - args: tool_call.parameters.to_string(), - }) - .await?; - - let tool = self - .registry - .get(&tool_call.name) - .ok_or_else(|| anyhow::anyhow!("Tool not found: {}", tool_call.name))?; - - let result = tool.execute(tool_call.parameters.clone()).await; - - let output = match result { - Ok(output) => output, - Err(e) => format!("Error: {}", e), - }; - - tx.send(AgentMessage::PlanningToolResult { - name: tool_call.name.clone(), - output: output.clone(), - }) - .await?; - - if tool_call.name == "write_file" - && let Some(path) = - tool_call.parameters.get("path").and_then(|p| p.as_str()) - && (path.ends_with("spec.json") || path.ends_with(".json")) - { - tx.send(AgentMessage::PlanningComplete { - spec_path: path.to_string(), - }) - .await?; - } - - self.conversation.push(Message::user(format!( - "Tool result for {}:\n{}", - tool_call.name, output - ))); - } - // Continue loop for next LLM response - } - } - } - - Ok(()) - } - - /// Continue conversation with additional user input - pub async fn continue_with_sender( - &mut self, - tx: AgentSender, - user_message: String, - ) -> anyhow::Result<()> { - self.run_with_sender(tx, user_message).await - } - /// Process a single conversation turn async fn process_turn(&mut self) -> anyhow::Result { loop { diff --git a/src/ralph/mod.rs b/src/ralph/mod.rs index dbe5829..e60b056 100644 --- a/src/ralph/mod.rs +++ b/src/ralph/mod.rs @@ -5,7 +5,6 @@ use crate::security::{SecurityValidator, permission::CliPermissionHandler}; use crate::spec::{Spec, TaskStatus}; use crate::tools::ToolRegistry; use crate::tools::factory::create_default_registry; -use crate::tui::messages::{AgentMessage, AgentSender}; use anyhow::{Context, Result}; use chrono::Utc; use std::sync::Arc; @@ -140,195 +139,6 @@ impl RalphLoop { Ok(()) } - /// Run execution with a message sender for TUI integration - pub async fn run_with_sender(&self, tx: AgentSender) -> Result<()> { - tx.send(AgentMessage::ExecutionStarted { - spec_path: self.spec_path.clone(), - }) - .await?; - - let mut iteration = 0; - - loop { - iteration += 1; - if iteration > self.max_iterations { - tx.send(AgentMessage::ExecutionError(format!( - "Reached max iterations ({})", - self.max_iterations - ))) - .await?; - break; - } - - let mut spec = Spec::load(&self.spec_path).context("Failed to load spec")?; - - let task = match spec.find_next_task() { - Some(t) => t.clone(), - None => { - tx.send(AgentMessage::ExecutionComplete).await?; - break; - } - }; - - tx.send(AgentMessage::TaskStarted { - task_id: task.id.clone(), - title: task.title.clone(), - }) - .await?; - - // Mark task as in progress - { - let task_mut = spec - .find_task_mut(&task.id) - .context("Task not found in spec")?; - task_mut.status = TaskStatus::InProgress; - } - spec.save(&self.spec_path).context("Failed to save spec")?; - - match self.execute_task_with_sender(&task.id, &tx).await { - Ok((signal, reason)) => { - let mut spec = Spec::load(&self.spec_path)?; - - match signal.as_str() { - "TASK_COMPLETE" => { - let task_mut = - spec.find_task_mut(&task.id).context("Task not found")?; - task_mut.status = TaskStatus::Complete; - task_mut.completed_at = Some(Utc::now()); - spec.save(&self.spec_path)?; - tx.send(AgentMessage::TaskComplete { task_id: task.id }) - .await?; - } - "TASK_BLOCKED" => { - let task_mut = - spec.find_task_mut(&task.id).context("Task not found")?; - task_mut.status = TaskStatus::Blocked; - spec.save(&self.spec_path)?; - tx.send(AgentMessage::TaskBlocked { - task_id: task.id, - reason: reason - .unwrap_or_else(|| "Task reported blocked".to_string()), - }) - .await?; - } - _ => { - let task_mut = - spec.find_task_mut(&task.id).context("Task not found")?; - task_mut.status = TaskStatus::Pending; - spec.save(&self.spec_path)?; - tx.send(AgentMessage::TaskResponse(format!( - "Unknown signal '{}', resetting task to pending", - signal - ))) - .await?; - } - } - } - Err(e) => { - tx.send(AgentMessage::ExecutionError(e.to_string())).await?; - break; - } - } - } - - Ok(()) - } - - async fn execute_task_with_sender( - &self, - task_id: &str, - tx: &AgentSender, - ) -> Result<(String, Option)> { - let context = self.build_context(task_id)?; - let tool_definitions = self.tools.definitions(); - - let mut messages = vec![Message::user(context)]; - - let max_turns = 50; - for _turn in 0..max_turns { - let response = self - .client - .chat(messages.clone(), &tool_definitions) - .await?; - - match response.content { - ResponseContent::Text(text) => { - tx.send(AgentMessage::TaskResponse(text.clone())).await?; - - if text.contains("TASK_COMPLETE") { - return Ok(("TASK_COMPLETE".to_string(), None)); - } - if text.contains("TASK_BLOCKED") { - return Ok(("TASK_BLOCKED".to_string(), Some(text))); - } - - messages.push(Message::assistant(text)); - } - ResponseContent::ToolCalls(tool_calls) => { - for tool_call in &tool_calls { - if tool_call.name == "signal_completion" { - let tool = self - .tools - .get(&tool_call.name) - .context("signal_completion tool not found")?; - let result = tool.execute(tool_call.parameters.clone()).await?; - - if result.starts_with("SIGNAL:complete:") { - return Ok(("TASK_COMPLETE".to_string(), None)); - } else if result.starts_with("SIGNAL:blocked:") { - let reason = result - .strip_prefix("SIGNAL:blocked:") - .map(|s| s.to_string()); - return Ok(("TASK_BLOCKED".to_string(), reason)); - } - } - } - - let mut results = Vec::new(); - for tool_call in tool_calls { - if tool_call.name == "signal_completion" { - continue; - } - - tx.send(AgentMessage::TaskToolCall { - name: tool_call.name.clone(), - args: tool_call.parameters.to_string(), - }) - .await?; - - let tool = self.tools.get(&tool_call.name).context("Tool not found")?; - - match tool.execute(tool_call.parameters).await { - Ok(output) => { - tx.send(AgentMessage::TaskToolResult { - name: tool_call.name.clone(), - output: output.clone(), - }) - .await?; - results - .push(format!("Tool: {}\nResult: {}", tool_call.name, output)); - } - Err(e) => { - tx.send(AgentMessage::TaskToolResult { - name: tool_call.name.clone(), - output: format!("Error: {}", e), - }) - .await?; - results.push(format!("Tool: {}\nError: {}", tool_call.name, e)); - } - } - } - - let results_text = results.join("\n\n"); - if !results_text.is_empty() { - messages.push(Message::user(results_text)); - } - } - } - } - - Err(anyhow::anyhow!("Reached max turns without completion")) - } async fn execute_task(&self, task_id: &str) -> Result { let context = self.build_context(task_id)?; diff --git a/src/tui/app.rs b/src/tui/app.rs deleted file mode 100644 index 3ba20d2..0000000 --- a/src/tui/app.rs +++ /dev/null @@ -1,377 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use crate::config::Config; -use crate::tui::messages::{AgentMessage, AgentSender}; -use crate::tui::views::{ - DashboardMode, DashboardState, ExecutionState, MessageRole, NavDirection, OutputItem, - PlanningState, ToolCall, -}; -use crate::tui::widgets::{HelpOverlay, SidePanel, Spinner}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActiveTab { - Dashboard, - Planning, - Execution, -} - -pub struct App { - pub running: bool, - pub active_tab: ActiveTab, - pub dashboard: DashboardState, - pub planning: PlanningState, - pub execution: ExecutionState, - pub side_panel: SidePanel, - pub spec_dir: String, - pub agent_tx: AgentSender, - pub config: Option, - pub spinner: Spinner, - pub help: HelpOverlay, - pub planning_active: bool, - pub execution_active: bool, -} - -impl App { - pub fn new(spec_dir: &str, agent_tx: AgentSender, config: Option) -> Self { - let mut dashboard = DashboardState::new(); - dashboard.load_specs(spec_dir); - - Self { - running: true, - active_tab: ActiveTab::Dashboard, - dashboard, - planning: PlanningState::new(), - execution: ExecutionState::new(), - side_panel: SidePanel::new(), - spec_dir: spec_dir.to_string(), - agent_tx, - config, - spinner: Spinner::new(), - help: HelpOverlay::new(), - planning_active: false, - execution_active: false, - } - } - - /// Handle key events. Uses full KeyEvent to preserve modifiers. - pub fn handle_key(&mut self, key: KeyEvent) { - // Help overlay is modal - only Esc closes it - if self.help.visible { - if key.code == KeyCode::Esc { - self.help.visible = false; - } - return; - } - - // Handle planning insert mode separately - if self.active_tab == ActiveTab::Planning && self.planning.insert_mode { - match key.code { - KeyCode::Esc => { - self.planning.insert_mode = false; - } - KeyCode::Enter => { - if let Some(text) = self.planning.submit_input() { - self.planning.add_message(MessageRole::User, text.clone()); - - // Don't spawn if already running - if self.planning.thinking { - return; - } - - // Spawn planning agent if we have config - if let Some(ref config) = self.config { - self.planning.thinking = true; - let tx = self.agent_tx.clone(); - let spec_dir = self.spec_dir.clone(); - let config = config.clone(); - - tokio::spawn(async move { - match crate::planning::PlanningAgent::new(config, spec_dir) { - Ok(mut agent) => { - if let Err(e) = - agent.run_with_sender(tx.clone(), text).await - { - let _ = tx - .send(AgentMessage::PlanningError(e.to_string())) - .await; - } - } - Err(e) => { - let _ = tx - .send(AgentMessage::PlanningError(e.to_string())) - .await; - } - } - }); - } - } - } - _ => { - self.planning.input.input(key); - } - } - return; - } - - match (key.code, key.modifiers) { - (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.running = false, - (KeyCode::Char('q'), KeyModifiers::NONE) => self.running = false, - (KeyCode::Char('1'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Dashboard, - (KeyCode::Char('2'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Planning, - (KeyCode::Char('3'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Execution, - (KeyCode::Tab, _) => { - self.active_tab = match self.active_tab { - ActiveTab::Dashboard => ActiveTab::Planning, - ActiveTab::Planning => ActiveTab::Execution, - ActiveTab::Execution => ActiveTab::Dashboard, - }; - } - (KeyCode::Char('K'), KeyModifiers::SHIFT) - if self.active_tab == ActiveTab::Dashboard => - { - self.dashboard.mode = DashboardMode::Kanban; - } - (KeyCode::Char('A'), KeyModifiers::SHIFT) - if self.active_tab == ActiveTab::Dashboard => - { - self.dashboard.mode = DashboardMode::Activity; - } - (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Dashboard => - { - self.dashboard.move_selection(NavDirection::Up); - } - (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Dashboard => - { - self.dashboard.move_selection(NavDirection::Down); - } - (KeyCode::Left, KeyModifiers::NONE) | (KeyCode::Char('h'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Dashboard => - { - self.dashboard.move_selection(NavDirection::Left); - } - (KeyCode::Right, KeyModifiers::NONE) | (KeyCode::Char('l'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Dashboard => - { - self.dashboard.move_selection(NavDirection::Right); - } - (KeyCode::Enter, KeyModifiers::NONE) if self.active_tab == ActiveTab::Dashboard => { - // Don't spawn if execution already running - if self.execution.running { - return; - } - - if let Some(spec) = self.dashboard.selected_spec() { - let spec_path = spec.path.clone(); - - if let Some(ref config) = self.config { - let tx = self.agent_tx.clone(); - let config = config.clone(); - - // Switch to execution tab - self.active_tab = ActiveTab::Execution; - - tokio::spawn(async move { - match crate::ralph::RalphLoop::new(config, spec_path, None) { - Ok(ralph) => { - if let Err(e) = ralph.run_with_sender(tx.clone()).await { - let _ = tx - .send(AgentMessage::ExecutionError(e.to_string())) - .await; - } - } - Err(e) => { - let _ = - tx.send(AgentMessage::ExecutionError(e.to_string())).await; - } - } - }); - } - } - } - (KeyCode::Char('i'), KeyModifiers::NONE) if self.active_tab == ActiveTab::Planning => { - self.planning.insert_mode = true; - } - // Planning tab scrolling (not in insert mode) - (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => - { - self.planning.scroll_up(1); - } - (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => - { - self.planning.scroll_down(1); - } - (KeyCode::PageUp, KeyModifiers::NONE) - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => - { - let page_size = self.planning.viewport_height.saturating_sub(1).max(1); - self.planning.scroll_up(page_size); - } - (KeyCode::PageDown, KeyModifiers::NONE) - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => - { - let page_size = self.planning.viewport_height.saturating_sub(1).max(1); - self.planning.scroll_down(page_size); - } - (KeyCode::Home, KeyModifiers::NONE) - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => - { - self.planning.scroll_offset = 0; - self.planning.auto_scroll = false; - } - (KeyCode::End, KeyModifiers::NONE) - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => - { - self.planning.scroll_to_bottom(); - } - // Execution tab scrolling - (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Execution => - { - self.execution.scroll_up(1); - } - (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) - if self.active_tab == ActiveTab::Execution => - { - self.execution.scroll_down(1); - } - (KeyCode::PageUp, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { - let page_size = self.execution.viewport_height.saturating_sub(1).max(1); - self.execution.scroll_up(page_size); - } - (KeyCode::PageDown, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { - let page_size = self.execution.viewport_height.saturating_sub(1).max(1); - self.execution.scroll_down(page_size); - } - (KeyCode::Home, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { - self.execution.scroll_offset = 0; - self.execution.auto_scroll = false; - } - (KeyCode::End, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { - self.execution.scroll_to_bottom(); - } - (KeyCode::Char('['), KeyModifiers::NONE) | (KeyCode::Char(']'), KeyModifiers::NONE) => { - self.side_panel.toggle(); - } - (KeyCode::Char('?'), KeyModifiers::NONE) => { - self.help.toggle(); - } - (KeyCode::Esc, _) => { - if self.side_panel.visible { - self.side_panel.visible = false; - } - } - _ => {} - } - } - - pub fn handle_agent_message(&mut self, msg: AgentMessage) { - match msg { - // Planning messages - AgentMessage::PlanningStarted => { - self.planning.thinking = true; - } - AgentMessage::PlanningResponse(text) => { - self.planning.thinking = false; - self.planning.add_message(MessageRole::Assistant, text); - } - AgentMessage::PlanningToolCall { name, args: _ } => { - self.planning - .add_message(MessageRole::Assistant, format!("[Calling tool: {}]", name)); - } - AgentMessage::PlanningToolResult { name, output } => { - let preview = if output.len() > 100 { - format!("{}...", &output[..100]) - } else { - output - }; - self.planning.add_message( - MessageRole::Assistant, - format!("[{} result: {}]", name, preview), - ); - } - AgentMessage::PlanningComplete { spec_path } => { - self.planning.thinking = false; - self.planning.add_message( - MessageRole::Assistant, - format!("✓ Spec saved to {}", spec_path), - ); - self.dashboard.load_specs(&self.spec_dir); - } - AgentMessage::PlanningError(err) => { - self.planning.thinking = false; - self.planning - .add_message(MessageRole::Assistant, format!("Error: {}", err)); - } - - // Execution messages - AgentMessage::ExecutionStarted { spec_path: _ } => { - self.execution.running = true; - self.execution.output.clear(); - } - AgentMessage::TaskStarted { task_id: _, title } => { - self.execution.add_output(OutputItem::Message { - role: "System".to_string(), - content: format!("Starting task: {}", title), - }); - } - AgentMessage::TaskResponse(text) => { - self.execution.add_output(OutputItem::Message { - role: "Assistant".to_string(), - content: text, - }); - } - AgentMessage::TaskToolCall { name, args } => { - self.execution.add_output(OutputItem::ToolCall(ToolCall { - name, - output: format!("Args: {}", args), - collapsed: true, - })); - } - AgentMessage::TaskToolResult { name, output } => { - self.execution.add_output(OutputItem::ToolCall(ToolCall { - name, - output, - collapsed: false, - })); - } - AgentMessage::TaskComplete { task_id } => { - self.execution.add_output(OutputItem::Message { - role: "System".to_string(), - content: format!("✓ Task {} complete", task_id), - }); - } - AgentMessage::TaskBlocked { task_id, reason } => { - self.execution.add_output(OutputItem::Message { - role: "System".to_string(), - content: format!("✗ Task {} blocked: {}", task_id, reason), - }); - } - AgentMessage::ExecutionComplete => { - self.execution.running = false; - self.execution.add_output(OutputItem::Message { - role: "System".to_string(), - content: "Execution complete".to_string(), - }); - self.dashboard.load_specs(&self.spec_dir); - } - AgentMessage::ExecutionError(err) => { - self.execution.running = false; - self.execution.add_output(OutputItem::Message { - role: "Error".to_string(), - content: err, - }); - } - } - } -} - -impl Default for App { - fn default() -> Self { - let (tx, _) = crate::tui::messages::agent_channel(); - Self::new("", tx, None) - } -} diff --git a/src/tui/messages.rs b/src/tui/messages.rs deleted file mode 100644 index 1a8fef4..0000000 --- a/src/tui/messages.rs +++ /dev/null @@ -1,30 +0,0 @@ -use tokio::sync::mpsc; - -#[derive(Debug, Clone)] -pub enum AgentMessage { - // Planning agent messages - PlanningStarted, - PlanningResponse(String), - PlanningToolCall { name: String, args: String }, - PlanningToolResult { name: String, output: String }, - PlanningComplete { spec_path: String }, - PlanningError(String), - - // Execution agent messages - ExecutionStarted { spec_path: String }, - TaskStarted { task_id: String, title: String }, - TaskResponse(String), - TaskToolCall { name: String, args: String }, - TaskToolResult { name: String, output: String }, - TaskComplete { task_id: String }, - TaskBlocked { task_id: String, reason: String }, - ExecutionComplete, - ExecutionError(String), -} - -pub type AgentSender = mpsc::Sender; -pub type AgentReceiver = mpsc::Receiver; - -pub fn agent_channel() -> (AgentSender, AgentReceiver) { - mpsc::channel(100) -} diff --git a/src/tui/mod.rs b/src/tui/mod.rs deleted file mode 100644 index 6a29520..0000000 --- a/src/tui/mod.rs +++ /dev/null @@ -1,99 +0,0 @@ -mod app; -pub mod messages; -mod ui; -pub mod views; -pub mod widgets; - -pub use messages::{AgentMessage, AgentReceiver, AgentSender, agent_channel}; - -pub use app::{ActiveTab, App}; -pub use ui::draw; - -use crossterm::{ - event::{self, Event, KeyEventKind}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{Terminal, backend::CrosstermBackend}; -use std::io; -use std::panic; -use std::time::Duration; -use tokio::sync::mpsc::Receiver; - -pub type Tui = Terminal>; - -pub fn setup_terminal() -> io::Result { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - Terminal::new(backend) -} - -pub fn restore_terminal(terminal: &mut Tui) -> io::Result<()> { - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - Ok(()) -} - -/// Run the TUI event loop with panic safety. -pub async fn run( - terminal: &mut Tui, - app: &mut App, - agent_rx: &mut Receiver, -) -> anyhow::Result<()> { - let original_hook = panic::take_hook(); - panic::set_hook(Box::new(|info| { - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - eprintln!("Panic: {}", info); - })); - - let result = run_loop(terminal, app, agent_rx).await; - - // Restore original panic hook - let _ = panic::take_hook(); - panic::set_hook(original_hook); - - result -} - -async fn run_loop( - terminal: &mut Tui, - app: &mut App, - agent_rx: &mut Receiver, -) -> anyhow::Result<()> { - let mut interval = tokio::time::interval(Duration::from_millis(50)); - - loop { - if !app.running { - break; - } - - terminal.draw(|frame| crate::tui::ui::draw(frame, app))?; - - tokio::select! { - _ = interval.tick() => { - app.spinner.tick(); - - while event::poll(Duration::ZERO)? { - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - app.handle_key(key); - } - Event::Resize(_, _) => { - // Redraw handled next iteration - } - _ => {} - } - } - } - - Some(msg) = agent_rx.recv() => { - app.handle_agent_message(msg); - } - } - } - Ok(()) -} diff --git a/src/tui/ui.rs b/src/tui/ui.rs deleted file mode 100644 index 71fc7dc..0000000 --- a/src/tui/ui.rs +++ /dev/null @@ -1,53 +0,0 @@ -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout}, - style::{Color, Style}, - text::{Line, Span}, - widgets::Paragraph, -}; - -use crate::tui::views::{draw_dashboard, draw_execution, draw_planning}; -use crate::tui::widgets::{TabBar, draw_help, draw_side_panel}; -use crate::tui::{ActiveTab, App}; - -pub fn draw(frame: &mut Frame, app: &mut App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Tab bar - Constraint::Min(0), // Main content - Constraint::Length(1), // Status bar - ]) - .split(frame.area()); - - // Tab bar - frame.render_widget(TabBar::new(app.active_tab), chunks[0]); - - // Main content area - match app.active_tab { - ActiveTab::Dashboard => { - draw_dashboard(frame, chunks[1], &app.dashboard); - } - ActiveTab::Planning => { - draw_planning(frame, chunks[1], &mut app.planning, app.spinner.current()); - } - ActiveTab::Execution => { - draw_execution(frame, chunks[1], &mut app.execution, app.spinner.current()); - } - } - - // Side panel (rendered on top of main content) - draw_side_panel(frame, chunks[1], &app.side_panel); - - // Help overlay (rendered on top of everything) - if app.help.visible { - draw_help(frame, frame.area()); - } - - // Status bar - let status = Line::from(vec![Span::raw( - " q quit │ 1/2/3 switch tabs │ Tab cycle │ ? help ", - )]); - let status_bar = Paragraph::new(status).style(Style::default().bg(Color::DarkGray)); - frame.render_widget(status_bar, chunks[2]); -} diff --git a/src/tui/views/dashboard.rs b/src/tui/views/dashboard.rs deleted file mode 100644 index c7aca95..0000000 --- a/src/tui/views/dashboard.rs +++ /dev/null @@ -1,295 +0,0 @@ -use std::fs; -use std::path::Path; - -use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, -}; - -use crate::spec::Spec; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DashboardMode { - Kanban, - Activity, -} - -pub struct DashboardState { - pub mode: DashboardMode, - pub specs: Vec, - pub selected_column: usize, - pub selected_row: usize, -} - -#[derive(Debug, Clone)] -pub struct SpecSummary { - pub name: String, - pub path: String, - pub status: SpecStatus, - pub task_progress: (usize, usize), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SpecStatus { - Draft, - Ready, - Running, - Completed, -} - -impl DashboardState { - pub fn new() -> Self { - Self { - mode: DashboardMode::Kanban, - specs: Vec::new(), - selected_column: 0, - selected_row: 0, - } - } - - pub fn load_specs(&mut self, spec_dir: &str) { - self.specs.clear(); - - let spec_path = Path::new(spec_dir); - if !spec_path.exists() { - return; - } - - if let Ok(entries) = fs::read_dir(spec_path) { - for entry in entries.flatten() { - let path = entry.path(); - - // Look for spec.json files - let spec_file = if path.is_dir() { - path.join("spec.json") - } else if path.extension().is_some_and(|e| e == "json") { - path - } else { - continue; - }; - - if let Ok(spec) = Spec::load(&spec_file) { - let completed = spec - .tasks - .iter() - .filter(|t| t.status == crate::spec::TaskStatus::Complete) - .count(); - let total = spec.tasks.len(); - - let status = if completed == total && total > 0 { - SpecStatus::Completed - } else if spec - .tasks - .iter() - .any(|t| t.status == crate::spec::TaskStatus::InProgress) - { - SpecStatus::Running - } else if total > 0 { - SpecStatus::Ready - } else { - SpecStatus::Draft - }; - - self.specs.push(SpecSummary { - name: spec.name, - path: spec_file.to_string_lossy().to_string(), - status, - task_progress: (completed, total), - }); - } - } - } - - // Clamp selection to valid range after reload - self.clamp_selection(); - } - - fn clamp_selection(&mut self) { - let count = self.specs_in_current_column().len(); - if count == 0 { - self.selected_row = 0; - } else { - self.selected_row = self.selected_row.min(count - 1); - } - } - - pub fn selected_spec(&self) -> Option<&SpecSummary> { - let statuses = [ - SpecStatus::Draft, - SpecStatus::Ready, - SpecStatus::Running, - SpecStatus::Completed, - ]; - let status = statuses.get(self.selected_column)?; - - self.specs - .iter() - .filter(|s| s.status == *status) - .nth(self.selected_row) - } - - pub fn move_selection(&mut self, direction: NavDirection) { - match direction { - NavDirection::Left => { - self.selected_column = self.selected_column.saturating_sub(1); - self.selected_row = 0; - } - NavDirection::Right => { - self.selected_column = (self.selected_column + 1).min(3); - self.selected_row = 0; - } - NavDirection::Up => { - self.selected_row = self.selected_row.saturating_sub(1); - } - NavDirection::Down => { - let count = self.specs_in_current_column().len(); - if count > 0 { - self.selected_row = (self.selected_row + 1).min(count - 1); - } - } - } - } - - fn specs_in_current_column(&self) -> Vec<&SpecSummary> { - let statuses = [ - SpecStatus::Draft, - SpecStatus::Ready, - SpecStatus::Running, - SpecStatus::Completed, - ]; - if let Some(status) = statuses.get(self.selected_column) { - self.specs.iter().filter(|s| s.status == *status).collect() - } else { - Vec::new() - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum NavDirection { - Up, - Down, - Left, - Right, -} - -impl Default for DashboardState { - fn default() -> Self { - Self::new() - } -} - -pub fn draw_dashboard(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(0)]) - .split(area); - - let mode_text = match state.mode { - DashboardMode::Kanban => " View: [K]anban │ Activity ", - DashboardMode::Activity => " View: Kanban │ [A]ctivity ", - }; - let mode_bar = Paragraph::new(mode_text).style(Style::default().fg(Color::Cyan)); - frame.render_widget(mode_bar, chunks[0]); - - match state.mode { - DashboardMode::Kanban => draw_kanban(frame, chunks[1], state), - DashboardMode::Activity => draw_activity(frame, chunks[1], state), - } -} - -fn draw_kanban(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - ]) - .split(area); - - let column_titles = ["Draft", "Ready", "Running", "Completed"]; - let statuses = [ - SpecStatus::Draft, - SpecStatus::Ready, - SpecStatus::Running, - SpecStatus::Completed, - ]; - - for (i, (col_area, (title, status))) in columns - .iter() - .zip(column_titles.iter().zip(statuses.iter())) - .enumerate() - { - let is_selected = i == state.selected_column; - let style = if is_selected { - Style::default().fg(Color::Yellow) - } else { - Style::default() - }; - - let block = Block::default() - .borders(Borders::ALL) - .title(*title) - .border_style(style); - - let specs_in_column: Vec<&SpecSummary> = - state.specs.iter().filter(|s| s.status == *status).collect(); - - let items: Vec = specs_in_column - .iter() - .enumerate() - .map(|(j, spec)| { - let content = format!( - "{} ({}/{})", - spec.name, spec.task_progress.0, spec.task_progress.1 - ); - let item_style = if is_selected && j == state.selected_row { - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - ListItem::new(content).style(item_style) - }) - .collect(); - - let list = List::new(items).block(block); - frame.render_widget(list, *col_area); - } -} - -fn draw_activity(frame: &mut ratatui::Frame, area: Rect, _state: &DashboardState) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Activity Feed "); - - let header = Line::from(vec![ - Span::styled("Time ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled("│ ", Style::default().fg(Color::DarkGray)), - Span::styled( - "Event ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled("│ ", Style::default().fg(Color::DarkGray)), - Span::styled( - "Spec ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled("│ ", Style::default().fg(Color::DarkGray)), - Span::styled("Details", Style::default().add_modifier(Modifier::BOLD)), - ]); - - let content = Paragraph::new(vec![ - header, - Line::from("─".repeat(area.width.saturating_sub(2) as usize)), - Line::from(" No activity yet"), - ]) - .block(block); - - frame.render_widget(content, area); -} diff --git a/src/tui/views/execution.rs b/src/tui/views/execution.rs deleted file mode 100644 index 2b8c3f8..0000000 --- a/src/tui/views/execution.rs +++ /dev/null @@ -1,313 +0,0 @@ -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, -}; - -use crate::spec::{Task, TaskStatus}; - -#[derive(Debug, Clone)] -pub struct ToolCall { - pub name: String, - pub output: String, - pub collapsed: bool, -} - -#[derive(Debug, Clone)] -pub enum OutputItem { - Message { role: String, content: String }, - ToolCall(ToolCall), -} - -const MAX_OUTPUT_ITEMS: usize = 1000; - -pub struct ExecutionState { - pub running: bool, - pub current_task: Option, - pub tasks: Vec, - pub output: Vec, - pub scroll_offset: usize, - pub auto_scroll: bool, - pub scroll_to_bottom_pending: bool, - pub content_height: usize, - pub viewport_height: usize, - pub last_area_width: u16, -} - -impl ExecutionState { - pub fn new() -> Self { - Self { - running: false, - current_task: None, - tasks: Vec::new(), - output: Vec::new(), - scroll_offset: 0, - auto_scroll: true, - scroll_to_bottom_pending: false, - content_height: 0, - viewport_height: 20, - last_area_width: 0, - } - } - - pub fn task_progress(&self) -> (usize, usize) { - let completed = self - .tasks - .iter() - .filter(|t| t.status == TaskStatus::Complete) - .count(); - (completed, self.tasks.len()) - } - - pub fn add_output(&mut self, item: OutputItem) { - self.output.push(item); - if self.output.len() > MAX_OUTPUT_ITEMS { - self.output.remove(0); - // Note: scroll_offset will be recalculated on next render - // based on actual content height, so this removal is handled - } - if self.auto_scroll { - // Set flag to scroll on next render - self.scroll_to_bottom_pending = true; - } - } - - pub fn clamp_scroll(&mut self, content_height: usize, viewport_height: usize) { - let max_scroll = content_height.saturating_sub(viewport_height); - self.scroll_offset = self.scroll_offset.min(max_scroll); - } - - pub fn scroll_up(&mut self, lines: usize) { - self.scroll_offset = self.scroll_offset.saturating_sub(lines); - // Disable auto-scroll when scrolling up from bottom - let near_bottom_threshold = 3; - if self.scroll_offset + near_bottom_threshold < self.max_scroll() { - self.auto_scroll = false; - } - } - - pub fn scroll_down(&mut self, lines: usize) { - let max_scroll = self.max_scroll(); - self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); - // Re-enable auto-scroll when near bottom (within 3 lines) - let near_bottom_threshold = 3; - if self.scroll_offset + near_bottom_threshold >= max_scroll { - self.auto_scroll = true; - } - } - - pub fn max_scroll(&self) -> usize { - // Guard against zero viewport height - if self.viewport_height == 0 { - return 0; - } - self.content_height.saturating_sub(self.viewport_height) - } - - pub fn scroll_to_bottom(&mut self) { - self.scroll_offset = self.max_scroll(); - self.auto_scroll = true; - } -} - -impl Default for ExecutionState { - fn default() -> Self { - Self::new() - } -} - -pub fn draw_execution( - frame: &mut Frame, - area: Rect, - state: &mut ExecutionState, - spinner_char: char, -) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) - .split(area); - - draw_task_pane(frame, chunks[0], state, spinner_char); - draw_output_pane(frame, chunks[1], state); -} - -fn draw_task_pane(frame: &mut Frame, area: Rect, state: &ExecutionState, spinner_char: char) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(8), Constraint::Min(0)]) - .split(area); - - let (completed, total) = state.task_progress(); - let title = if state.running { - format!(" Current Task {}/{} {} ", completed, total, spinner_char) - } else { - format!(" Current Task {}/{} ", completed, total) - }; - let current_block = Block::default().borders(Borders::ALL).title(title); - - let current_content = if let Some(task) = &state.current_task { - let mut lines = vec![ - Line::from(Span::styled( - format!("▶ {}", task.title), - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "Acceptance Criteria:", - Style::default().fg(Color::Cyan), - )), - ]; - for criterion in &task.acceptance_criteria { - lines.push(Line::from(format!(" ☐ {}", criterion))); - } - lines - } else { - vec![Line::from("No task running")] - }; - - let current = Paragraph::new(current_content) - .block(current_block) - .wrap(Wrap { trim: false }); - frame.render_widget(current, chunks[0]); - - let tasks_block = Block::default().borders(Borders::ALL).title(" Tasks "); - - let items: Vec = state - .tasks - .iter() - .map(|task| { - let (icon, style) = match task.status { - TaskStatus::Complete => ("✓", Style::default().fg(Color::Green)), - TaskStatus::InProgress => ("▶", Style::default().fg(Color::Yellow)), - TaskStatus::Pending => ("○", Style::default().fg(Color::DarkGray)), - TaskStatus::Blocked => ("✗", Style::default().fg(Color::Red)), - }; - ListItem::new(format!(" {} {}", icon, task.title)).style(style) - }) - .collect(); - - let list = List::new(items).block(tasks_block); - frame.render_widget(list, chunks[1]); -} - -fn draw_output_pane(frame: &mut Frame, area: Rect, state: &mut ExecutionState) { - let block = Block::default().borders(Borders::ALL).title(" Output "); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let mut lines: Vec = Vec::new(); - - for item in &state.output { - match item { - OutputItem::Message { role, content } => { - let style = if role == "Assistant" { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - }; - lines.push(Line::from(Span::styled(role.clone(), style))); - for line in content.lines() { - lines.push(Line::from(format!(" {}", line))); - } - lines.push(Line::from("")); - } - OutputItem::ToolCall(tc) => { - lines.push(Line::from(vec![ - Span::raw("┌─ "), - Span::styled( - tc.name.clone(), - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" ─"), - ])); - if !tc.collapsed { - for line in tc.output.lines().take(5) { - lines.push(Line::from(format!("│ {}", line))); - } - } - lines.push(Line::from("└────────────────────")); - lines.push(Line::from("")); - } - } - } - - if lines.is_empty() { - lines.push(Line::from(" Waiting for execution...")); - } - - // Calculate actual wrapped content height - let viewport_height = inner.height as usize; - let viewport_width = inner.width as usize; - - // Calculate wrapped line count - let mut content_height = 0; - for line in &lines { - let line_width = line.width(); - if line_width == 0 { - content_height += 1; - } else { - // Account for wrapping - content_height += (line_width + viewport_width - 1) / viewport_width.max(1); - } - } - - // Update state and handle pending scroll - state.content_height = content_height; - state.viewport_height = viewport_height; - state.last_area_width = inner.width; - - if state.scroll_to_bottom_pending { - state.scroll_offset = content_height.saturating_sub(viewport_height); - state.scroll_to_bottom_pending = false; - } - - // Clamp scroll - state.clamp_scroll(content_height, viewport_height); - - let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; - - let paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .scroll((scroll_y, 0)); - - frame.render_widget(paragraph, inner); - - // Show scroll indicator if content is scrollable - if content_height > viewport_height { - let max_scroll = content_height.saturating_sub(viewport_height); - let scroll_pct = if max_scroll > 0 { - (state.scroll_offset * 100) / max_scroll - } else { - 100 - }; - - // Add [auto] indicator if auto-scroll is enabled - let indicator = if state.auto_scroll { - format!(" {}% [auto] ", scroll_pct) - } else { - format!(" {}% ", scroll_pct) - }; - - let indicator_width = indicator.len() as u16; - let indicator_area = Rect::new( - inner.x + inner.width.saturating_sub(indicator_width), - inner.y + inner.height.saturating_sub(1), - indicator_width, - 1, - ); - - let indicator_widget = - Paragraph::new(indicator).style(Style::default().bg(Color::DarkGray).fg(Color::White)); - - frame.render_widget(indicator_widget, indicator_area); - } -} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs deleted file mode 100644 index e0d8263..0000000 --- a/src/tui/views/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod dashboard; -mod execution; -mod planning; - -pub use dashboard::{ - DashboardMode, DashboardState, NavDirection, SpecStatus, SpecSummary, draw_dashboard, -}; -pub use execution::{ExecutionState, OutputItem, ToolCall, draw_execution}; -pub use planning::{ChatMessage, MessageRole, PlanningState, draw_planning}; diff --git a/src/tui/views/planning.rs b/src/tui/views/planning.rs deleted file mode 100644 index c2488a7..0000000 --- a/src/tui/views/planning.rs +++ /dev/null @@ -1,268 +0,0 @@ -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use tui_textarea::TextArea; - -#[derive(Debug, Clone)] -pub struct ChatMessage { - pub role: MessageRole, - pub content: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageRole { - User, - Assistant, -} - -pub struct PlanningState { - pub messages: Vec, - pub input: TextArea<'static>, - pub insert_mode: bool, - pub scroll_offset: usize, - pub thinking: bool, - pub auto_scroll: bool, - pub scroll_to_bottom_pending: bool, - pub content_height: usize, - pub viewport_height: usize, - pub last_area_width: u16, -} - -impl PlanningState { - pub fn new() -> Self { - let mut input = TextArea::default(); - input.set_cursor_line_style(Style::default()); - input.set_placeholder_text("Type your message..."); - - Self { - messages: vec![ChatMessage { - role: MessageRole::Assistant, - content: "What would you like to build?".to_string(), - }], - input, - insert_mode: false, - scroll_offset: 0, - thinking: false, - auto_scroll: true, - scroll_to_bottom_pending: false, - content_height: 0, - viewport_height: 20, - last_area_width: 0, - } - } - - pub fn add_message(&mut self, role: MessageRole, content: String) { - self.messages.push(ChatMessage { role, content }); - if self.auto_scroll { - // Set flag to scroll on next render - self.scroll_to_bottom_pending = true; - } - } - - pub fn submit_input(&mut self) -> Option { - let text: String = self.input.lines().join("\n"); - if text.trim().is_empty() { - return None; - } - self.input.select_all(); - self.input.cut(); - Some(text) - } - - pub fn scroll_up(&mut self, lines: usize) { - self.scroll_offset = self.scroll_offset.saturating_sub(lines); - // Disable auto-scroll when scrolling up from bottom - let near_bottom_threshold = 3; - if self.scroll_offset + near_bottom_threshold < self.max_scroll() { - self.auto_scroll = false; - } - } - - pub fn scroll_down(&mut self, lines: usize) { - let max_scroll = self.max_scroll(); - self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); - // Re-enable auto-scroll when near bottom (within 3 lines) - let near_bottom_threshold = 3; - if self.scroll_offset + near_bottom_threshold >= max_scroll { - self.auto_scroll = true; - } - } - - pub fn max_scroll(&self) -> usize { - // Guard against zero viewport height - if self.viewport_height == 0 { - return 0; - } - self.content_height.saturating_sub(self.viewport_height) - } - - pub fn scroll_to_bottom(&mut self) { - self.scroll_offset = self.max_scroll(); - self.auto_scroll = true; - } -} - -impl Default for PlanningState { - fn default() -> Self { - Self::new() - } -} - -pub fn draw_planning(frame: &mut Frame, area: Rect, state: &mut PlanningState, spinner_char: char) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), - Constraint::Length(3), - Constraint::Length(1), - ]) - .split(area); - - draw_chat_history(frame, chunks[0], state, spinner_char); - - let input_block = Block::default() - .borders(Borders::ALL) - .border_style(if state.insert_mode { - Style::default().fg(Color::Green) - } else { - Style::default() - }) - .title(if state.insert_mode { - " Input (INSERT) " - } else { - " Input " - }); - - state.input.set_block(input_block); - frame.render_widget(&state.input, chunks[1]); - - let hints = if state.insert_mode { - " Esc exit insert │ Enter send " - } else { - " i insert │ ↑↓ scroll │ Ctrl+S save │ ] spec panel " - }; - let hints_bar = Paragraph::new(hints).style(Style::default().fg(Color::DarkGray)); - frame.render_widget(hints_bar, chunks[2]); -} - -fn draw_chat_history(frame: &mut Frame, area: Rect, state: &mut PlanningState, spinner_char: char) { - let block = Block::default().borders(Borders::ALL).title(" Chat "); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let mut lines: Vec = Vec::new(); - - for msg in &state.messages { - let (label, style) = match msg.role { - MessageRole::User => ( - "You", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - MessageRole::Assistant => ( - "Assistant", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - }; - - lines.push(Line::from(Span::styled(label, style))); - - for line in msg.content.lines() { - lines.push(Line::from(format!(" {}", line))); - } - lines.push(Line::from("")); - } - - if state.thinking { - lines.push(Line::from(vec![ - Span::styled( - "Assistant", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(spinner_char.to_string(), Style::default().fg(Color::Yellow)), - ])); - } - - // Calculate actual wrapped content height - let viewport_height = inner.height as usize; - let viewport_width = inner.width as usize; - - // Calculate wrapped line count - let mut content_height = 0; - for line in &lines { - let line_width = line.width(); - if line_width == 0 { - content_height += 1; - } else { - // Account for wrapping - content_height += (line_width + viewport_width - 1) / viewport_width.max(1); - } - } - - // Update state dimensions - state.content_height = content_height; - state.viewport_height = viewport_height; - state.last_area_width = inner.width; - - // Handle pending scroll to bottom - if state.scroll_to_bottom_pending { - state.scroll_offset = content_height.saturating_sub(viewport_height); - state.scroll_to_bottom_pending = false; - } - - // Clamp scroll to valid range - let max_scroll = if viewport_height == 0 { - 0 - } else { - content_height.saturating_sub(viewport_height) - }; - state.scroll_offset = state.scroll_offset.min(max_scroll); - - let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; - - let paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .scroll((scroll_y, 0)); - - frame.render_widget(paragraph, inner); - - // Show scroll indicator if content is scrollable - if content_height > viewport_height { - let scroll_pct = if max_scroll > 0 { - (state.scroll_offset * 100) / max_scroll - } else { - 100 - }; - - // Add [auto] indicator if auto-scroll is enabled - let indicator = if state.auto_scroll { - format!(" {}% [auto] ", scroll_pct) - } else { - format!(" {}% ", scroll_pct) - }; - - let indicator_width = indicator.len() as u16; - let indicator_area = Rect::new( - inner.x + inner.width.saturating_sub(indicator_width), - inner.y + inner.height.saturating_sub(1), - indicator_width, - 1, - ); - - let indicator_widget = - Paragraph::new(indicator).style(Style::default().bg(Color::DarkGray).fg(Color::White)); - - frame.render_widget(indicator_widget, indicator_area); - } -} diff --git a/src/tui/widgets/help.rs b/src/tui/widgets/help.rs deleted file mode 100644 index 294dbb8..0000000 --- a/src/tui/widgets/help.rs +++ /dev/null @@ -1,84 +0,0 @@ -use ratatui::{ - Frame, - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -pub struct HelpOverlay { - pub visible: bool, -} - -impl HelpOverlay { - pub fn new() -> Self { - Self { visible: false } - } - - pub fn toggle(&mut self) { - self.visible = !self.visible; - } -} - -impl Default for HelpOverlay { - fn default() -> Self { - Self::new() - } -} - -pub fn draw_help(frame: &mut Frame, area: Rect) { - let width = 50.min(area.width.saturating_sub(4)); - let height = 16.min(area.height.saturating_sub(4)); - let x = (area.width - width) / 2; - let y = (area.height - height) / 2; - let help_area = Rect::new(x, y, width, height); - - frame.render_widget(Clear, help_area); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(" Help (Esc to close) "); - - let lines = vec![ - Line::from(Span::styled( - "Global", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(" q Quit"), - Line::from(" 1/2/3 Switch tabs"), - Line::from(" Tab Cycle tabs"), - Line::from(" [ ] Toggle side panel"), - Line::from(" ? Toggle help"), - Line::from(""), - Line::from(Span::styled( - "Dashboard", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(" Shift+K/A Kanban/Activity view"), - Line::from(" ↑↓←→/hjkl Navigate"), - Line::from(" Enter Run selected spec"), - Line::from(""), - Line::from(Span::styled( - "Planning", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(" i Enter insert mode"), - Line::from(" Esc Exit insert mode"), - Line::from(" Enter Send message"), - Line::from(" ↑↓/jk Scroll (not in insert)"), - Line::from(" PgUp/PgDn Page scroll"), - Line::from(" Home/End Jump to top/bottom"), - Line::from(""), - Line::from(Span::styled( - "Execution", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(" ↑↓/jk Scroll output"), - Line::from(" PgUp/PgDn Page scroll"), - Line::from(" Home/End Jump to top/bottom"), - ]; - - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, help_area); -} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs deleted file mode 100644 index 64e6368..0000000 --- a/src/tui/widgets/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod help; -mod panel; -mod spinner; -mod tabs; - -pub use help::{HelpOverlay, draw_help}; -pub use panel::{SidePanel, draw_side_panel}; -pub use spinner::Spinner; -pub use tabs::TabBar; diff --git a/src/tui/widgets/panel.rs b/src/tui/widgets/panel.rs deleted file mode 100644 index 0f13dd1..0000000 --- a/src/tui/widgets/panel.rs +++ /dev/null @@ -1,66 +0,0 @@ -use ratatui::{ - Frame, - layout::Rect, - style::{Color, Style}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, -}; - -pub struct SidePanel { - pub visible: bool, - pub title: String, - pub content: String, - pub width_percent: u16, -} - -impl SidePanel { - pub fn new() -> Self { - Self { - visible: false, - title: String::new(), - content: String::new(), - width_percent: 40, - } - } - - pub fn toggle(&mut self) { - self.visible = !self.visible; - } - - pub fn set_content(&mut self, title: &str, content: String) { - self.title = title.to_string(); - self.content = content; - } -} - -impl Default for SidePanel { - fn default() -> Self { - Self::new() - } -} - -pub fn draw_side_panel(frame: &mut Frame, area: Rect, panel: &SidePanel) { - if !panel.visible { - return; - } - - let panel_width = (area.width as u32 * panel.width_percent as u32 / 100) as u16; - let panel_area = Rect { - x: area.x + area.width - panel_width, - y: area.y, - width: panel_width, - height: area.height, - }; - - frame.render_widget(Clear, panel_area); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(format!(" {} ", panel.title)); - - let content = Paragraph::new(panel.content.clone()) - .block(block) - .wrap(Wrap { trim: false }); - - frame.render_widget(content, panel_area); -} diff --git a/src/tui/widgets/spinner.rs b/src/tui/widgets/spinner.rs deleted file mode 100644 index 0016686..0000000 --- a/src/tui/widgets/spinner.rs +++ /dev/null @@ -1,25 +0,0 @@ -const SPINNER_FRAMES: &[char] = &['◐', '◓', '◑', '◒']; - -pub struct Spinner { - frame: usize, -} - -impl Spinner { - pub fn new() -> Self { - Self { frame: 0 } - } - - pub fn tick(&mut self) { - self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); - } - - pub fn current(&self) -> char { - SPINNER_FRAMES[self.frame] - } -} - -impl Default for Spinner { - fn default() -> Self { - Self::new() - } -} diff --git a/src/tui/widgets/tabs.rs b/src/tui/widgets/tabs.rs deleted file mode 100644 index 6d06cfe..0000000 --- a/src/tui/widgets/tabs.rs +++ /dev/null @@ -1,46 +0,0 @@ -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Tabs as RataTabs, Widget}, -}; - -use crate::tui::ActiveTab; - -pub struct TabBar { - active: ActiveTab, -} - -impl TabBar { - pub fn new(active: ActiveTab) -> Self { - Self { active } - } -} - -impl Widget for TabBar { - fn render(self, area: Rect, buf: &mut Buffer) { - let titles = vec![ - Line::from("[1] Dashboard"), - Line::from("[2] Planning"), - Line::from("[3] Execution"), - ]; - let selected = match self.active { - ActiveTab::Dashboard => 0, - ActiveTab::Planning => 1, - ActiveTab::Execution => 2, - }; - - let tabs = RataTabs::new(titles) - .select(selected) - .style(Style::default().fg(Color::White)) - .highlight_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .divider(Span::raw(" │ ")); - - tabs.render(area, buf); - } -}