fix(cli): route watch/loop capability state through the effective state dir - #1660
Conversation
…te dir Fixes bradygaster#1490 squad watch and squad loop resolve externalized state for team.md/routing.md startup reads only, then hand every capability teamRoot and let them build .squad/ paths from it directly. After squad externalize the local .squad/ is a marker-only stub, so decision-hygiene, cleanup, retro, subsquad discovery, the capabilities/ loader, and the ralph-instructions.md check all silently saw an empty directory instead of the real state. Added stateRoot to WatchContext, populated from effectiveSquadDir().stateDir in both runWatch and runLoop (the compiler caught runLoop's missing field once stateRoot became required — same gap, different entry point). Every capability now reads/writes through context.stateRoot instead of joining .squad/ onto teamRoot. loadExternalCapabilities() and buildAgentPrompt() got an optional override parameter rather than a signature change, so their existing {teamRoot}/.squad/... default and every test built against it keep working unchanged. notes-promote.ts needed no change — it only reads the always-local config.json for backend-type detection; two-layer's actual state is git-notes/orphan-branch, not filesystem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🛫 PR Readiness Check
PR Scope: 📦🔧 Mixed (product + infrastructure)
|
| Status | Check | Details |
|---|---|---|
| ❌ | Single commit | 2 commits — consider squashing before review |
| ✅ | Not in draft | Ready for review |
| ✅ | Branch up to date | Up to date with dev |
| ❌ | Copilot review | No Copilot review yet — it may still be processing |
| ✅ | Changeset present | Changeset file found |
| ✅ | Scope clean | No .squad/ or docs/proposals/ files |
| ✅ | No merge conflicts | No merge conflicts |
| ✅ | Copilot threads resolved | No Copilot review threads |
| ❌ | CI passing | 7 check(s) still running |
Files Changed (15 files, +320 −17)
| File | +/− |
|---|---|
.changeset/fix-1490-watch-external-state.md |
+7 −0 |
packages/squad-cli/src/cli/commands/loop.ts |
+1 −0 |
packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts |
+2 −3 |
packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts |
+2 −2 |
packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts |
+10 −3 |
packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts |
+1 −1 |
packages/squad-cli/src/cli/commands/watch/external-loader.ts |
+5 −2 |
packages/squad-cli/src/cli/commands/watch/index.ts |
+13 −6 |
packages/squad-cli/src/cli/commands/watch/types.ts |
+7 −0 |
test/cli/watch-agent-spawn.test.ts |
+1 −0 |
test/cli/watch-capabilities.test.ts |
+118 −0 |
test/cli/watch-external-loader.test.ts |
+79 −0 |
test/watch-cleanup.test.ts |
+1 −0 |
test/watch-notes-promote.test.ts |
+1 −0 |
test/watch-subsquad-discovery.test.ts |
+72 −0 |
Total: +320 −17
This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.
🟡 Impact Analysis — PR #1660Risk tier: 🟡 MEDIUM 📊 Summary
🎯 Risk Factors
📦 Modules Affectedroot (1 file)
squad-cli (8 files)
tests (6 files)
|
|
CI's test job failure is unrelated — |
|
👋 Friendly nudge — this PR has had no activity for 9 days. What needs attention:
If this PR is abandoned, please close it. If it's blocked on something external, leave a comment so the team knows. |
What
squad watchandsquad loopnow thread the effective state directory (stateRoot) throughWatchContextand every capability, instead of each capability rebuilding.squad/paths fromteamRooton its own.Why
Closes #1490
Full repro and root cause are in the issue — the short version:
runWatch/runLoopalready calleffectiveSquadDir()and use the external-awarestateDirforteam.md/routing.md, but every capability was still handed plainteamRootand joined.squad/onto it directly. Aftersquad externalize, the local.squad/is a marker-only stub (config.jsonplus a few otherKEEP_LOCALfiles), so every capability doingpath.join(teamRoot, '.squad', ...)silently found nothing: decision-hygiene's inbox count, cleanup's scratch/log/orchestration-log pruning, retro's staleness check, subsquad discovery, thecapabilities/external loader, and theralph-instructions.mdlookup inexecute.ts.How
types.ts: addedstateRoot: stringtoWatchContext.watch/index.ts/loop.ts: setstateRoot: stateDirinbaseContext—stateDirwas already computed viaeffectiveSquadDir()in both files, just never threaded further. The compiler caughtloop.ts's missing field the momentstateRootbecame required — same bug, second entry point, would've been an easy miss by hand.decision-hygiene.ts,cleanup.ts,retro.ts: swappedpath.join(context.teamRoot, '.squad', ...)forpath.join(context.stateRoot, ...).discoverSubSquads()(private inwatch/index.ts, only caller wasrunWatchitself): same swap, now takesstateRootdirectly. Exported it (matching the existingreportBoardprecedent in the same file) so it's actually testable.loadExternalCapabilities()andbuildAgentPrompt(): both took a bareteamRootand joined.squad/internally, with existing test suites built around that exact contract (7 tests for the loader, 4 for the prompt builder). Rather than changing what they join, both got a new optional param (capabilitiesDir/stateDir) that wins when passed — zero behavior change for any existing 2-arg caller, callers with the full context now passcontext.stateRootas the extra arg.notes-promote.ts: left unchanged, differs from what the issue originally listed. It only reads.squad/config.jsonfor backend-type detection —config.jsonis deliberatelyKEEP_LOCALinexternalize.ts, and the two-layer backend's actual state (git-notes + orphan branch) lives in git refs, not the filesystem, so externalization doesn't touch any of it. Flagging this as a correction to the issue's own root-cause list, not silently dropping scope.Testing
Regression tests added to the existing suites, all following each file's established mock/fixture style — no new conventions:
test/cli/watch-capabilities.test.ts: newexternalized state (#1490)blocks underCleanupCapabilityandDecisionHygieneCapability, a newRetroCapabilitydescribe block (didn't exist before), and abuildAgentPromptoverride test — each givesteamRoot/stateRootdifferent fake paths and asserts the mocked storage calls only ever touchstateRoot.test/cli/watch-external-loader.test.ts: new block provingcapabilitiesDirwins over theteamRoot-derived default, and that omitting it preserves the old default exactly (existing 7 tests untouched).test/watch-subsquad-discovery.test.ts(new file): real-filesystem test for the now-exporteddiscoverSubSquads, including one that writes a subsquad only under an "external" dir and shows it's invisible from the local.squad/— the actual shape of the bug.devfirst (stashed the 8 source files, kept the tests): 12 failures — the exact ones exercising the fix (Windows-path assertions, missing export, override ignored). Restored the source, all pass.The 1 failure (
buildAgentPrompt > checks .squad/ralph-instructions.md inside teamRoot) is a pre-existing Windows path-separator mismatch, confirmed present on unmodifieddev— untouched by this PR, same function, different (2-arg, unchanged-behavior) call path.Full-suite (
npx vitest run, freshdistfor both packages): 87 failures / 7232 passed. Checked every uniquely-named failing file against unmodifieddev:test/cli/watch.test.ts(cold dynamic-import 5s timeout) andtest/cli/upgrade.test.ts(casting_policy_version1.1 vs 1.2, unrelated template drift) both fail identically with my 13 source files stashed — pre-existing, not branch-caused.npx tsc --noEmit -p packages/squad-cli/tsconfig.jsonclean.eslinton all 15 changed files: 0 errors, 107 pre-existingno-console/n/no-syncwarnings matching each file's existing style, nothing new.Diff hygiene:
git diffvsgit diff -widentical on every file (0 lines difference), including the two files that turned out to have mixed CRLF/LF/BOM conventions on this box (cleanup.tsis CRLF+BOM, most of the rest LF) — verified each againstgit show HEAD:<file>directly rather than the working-tree copy, since this session hit a real discrepancy where several files read back as CRLF on disk despite being LF in the actual git blob..changeset/fix-1490-watch-external-state.md(patch@bradygaster/squad-cli)PR Readiness Checklist
Branch & Commit
devdevBuild & Test
npm run build -w packages/squad-clipassesnpm test— targeted suites 97/98 (1 pre-existing unrelated failure); full-suite baseline compared against unmodifieddevnpm run lintpasses (tsc clean)eslint— 0 errorsChangeset
Docs
Exports
discoverSubSquads/SubSquadnewly exported fromwatch/index.tsfor testability, matching the existingreportBoardpattern in the same file — not a new module, no subpath export changeBreaking Changes
None.
WatchContext.stateRootis a new required field, but the only two places that construct aWatchContextfrom scratch (runWatch,runLoop) both already hadstateDirin scope and now populate it. External capabilities implementingWatchCapabilityreceive a richer context, not a different one.Waivers
None.