From fd547826918351e302003acda9a153388f8bf356 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 9 Nov 2026 18:32:32 +0200 Subject: [PATCH 1/2] fix(state): transactional governed-memory migration and source-branch casting ownership - Align casting state (casting/*.json) as source-branch-owned across git-notes pre-commit guard, worktree gitignore rules, state MCP tool rejection (CASTING_STATE_SOURCE_OWNED), coordinator templates, and docs (Fixes #2229) - Migrate governed memory (memory/{agent}/{tier}/**) and decision inbox (decisions/inbox/*.md) transactionally with byte-preserving writes, legacy memories/ normalization, destination rollback on failure, and clean source pruning (Fixes #2425) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/casting-source-branch-ownership.md | 6 + .../governed-memory-backend-migration.md | 5 + .github/agents/squad.agent.md | 19 +- .squad-templates/squad.agent.md | 19 +- CHANGELOG.md | 6 + .../content/docs/features/state-backends.md | 14 +- .../src/cli/commands/install-hooks.ts | 27 ++- .../src/cli/commands/migrate-backend.ts | 218 +++++++++++------- .../templates/squad.agent.md.template | 19 +- .../squad-sdk/src/config/gitignore-state.ts | 26 ++- packages/squad-sdk/src/tools/index.ts | 3 +- .../templates/squad.agent.md.template | 19 +- templates/squad.agent.md.template | 19 +- test/cli/state-mcp.test.ts | 4 +- test/gitignore-state.test.ts | 32 +++ test/install-hooks-wi1.test.ts | 76 +++++- test/state-backend.test.ts | 8 +- test/tools.test.ts | 4 +- test/upgrade-state-backend.test.ts | 137 ++++++++++- 19 files changed, 494 insertions(+), 167 deletions(-) create mode 100644 .changeset/casting-source-branch-ownership.md create mode 100644 .changeset/governed-memory-backend-migration.md diff --git a/.changeset/casting-source-branch-ownership.md b/.changeset/casting-source-branch-ownership.md new file mode 100644 index 000000000..ffa653b9b --- /dev/null +++ b/.changeset/casting-source-branch-ownership.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +Keep all casting configuration on the normal source branch for every state backend. Refresh existing hooks without retaining the obsolete casting block, reject runtime casting policy mutations, and align memory/identity safeguards and coordinator instructions with their actual scope. diff --git a/.changeset/governed-memory-backend-migration.md b/.changeset/governed-memory-backend-migration.md new file mode 100644 index 000000000..901246ecf --- /dev/null +++ b/.changeset/governed-memory-backend-migration.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Preserve governed memory when migrating local/worktree storage to orphan/two-layer storage, including decision inbox records, the array index, configuration, audit archives and tombstones. Abort on destination conflicts or transfer failure before changing backend authority, and retain source files until the config write succeeds so identical imports can be retried. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 685881668..32eab19d8 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -131,7 +131,9 @@ requirements remain in `.github/PR_REQUIREMENTS.md`. **State-backend handshake — MANDATORY on every session before any state mutation (bradygaster/squad#1305):** -For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/casting/*.json`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes either fail at the pre-commit hook or create phantom state the runtime overwrites at next read — a contract violation that produces silent data loss. +For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes create phantom state outside the configured store. For orphan/two-layer backends, Git ignores decisions, histories, memory and identity; the pre-commit hook rejects staged additions or edits to decisions.md, agent histories, memory and identity. These safeguards do not cover every runtime path or git-notes; the runtime tool contract still applies. + +**Casting ownership:** `.squad/casting/` is static source-branch configuration for every backend, not runtime state. Read and edit policy on disk and commit casting changes on the normal source branch. Registry/history updates must use the atomic casting protocol (`commitCastingRegistryPair`), including its commit marker. Do not write casting through `squad_state_*` or move it onto `squad-state`. The `squad_state_*` and `memory.*` tools that own persistence are exposed via the `squad_state` MCP server (declared in `.mcp.json`). Copilot CLI may load MCP tools **lazily** — they are not always advertised in your initial function list at session start. You MUST proactively confirm they are reachable: @@ -289,13 +291,12 @@ If `memory.*` is not present in the bridge (older Squad versions before the brid - `.squad/decisions.md` - `.squad/decisions/inbox/**` - `.squad/agents/*/history.md` -- `.squad/casting/*.json` - `.squad/identity/*.md` - `.squad/memory/**` - `.squad/rai/audit-trail.md` - `.squad/fact-checker/audit-trail.md` -These are runtime-managed paths under non-local backends. Hand-writing creates phantom state. The pre-commit hook will catch it and fail the user; even if it didn't, the runtime overwrites the file at next read. Report the missing bridge and halt instead. +These are runtime-managed paths under non-local backends. Hand-writing creates phantom state outside the configured store. Git safeguards cover only the subset described above, not every path in this list. Report the missing bridge and halt instead. Casting remains source-branch configuration and is not blocked by a missing runtime bridge. For `STATE_BACKEND ∈ {"local", "worktree"}`, file writes to `.squad/` are valid because the local backend IS the filesystem. @@ -655,7 +656,7 @@ If the user says "I need a designer" or "add someone for DevOps": 1. **Allocate a name** from the current assignment's universe (read from `.squad/casting/history.json`). If the universe is exhausted, apply overflow handling (see Casting & Persistent Naming → Overflow Handling). 2. **Check plugin marketplaces.** If `.squad/plugins/marketplaces.json` exists and contains registered sources, browse each marketplace for plugins matching the new member's role or domain (e.g., "azure-cloud-development" for an Azure DevOps role). Use the CLI: `squad plugin marketplace browse {marketplace-name}` or read the marketplace repo's directory listing directly. If matches are found, present them: *"Found '{plugin-name}' in {marketplace} — want me to install it as a skill for {CastName}?"* If the user accepts, copy the plugin content into `.squad/skills/{plugin-name}/SKILL.md` or merge relevant instructions into the agent's charter. If no marketplaces are configured, skip silently. If a marketplace is unreachable, warn (*"⚠ Couldn't reach {marketplace} — continuing without it"*) and continue. 3. Generate a new charter.md + history.md (seeded with project context from team.md), using the cast name. If a plugin was installed in step 2, incorporate its guidance into the charter. -4. **Update `.squad/casting/registry.json`** with the new agent entry. +4. **Update the casting pair** with the new agent entry using the atomic on-disk protocol described above, not a standalone registry write. 5. Add to team.md roster. 6. Add routing entries to routing.md. 7. Run `squad upgrade` to regenerate Team Capabilities. @@ -667,7 +668,7 @@ If the user wants to remove someone: 1. Move their folder to `.squad/agents/_alumni/{name}/` 2. Remove from team.md roster 3. Update routing.md -4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. +4. **Update the casting pair atomically**: set the registry entry's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. 5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references. 6. Their knowledge is preserved, just inactive. @@ -737,8 +738,8 @@ After selecting a naming mode: 5. **Ralph is always "Ralph"** — exempt from casting. 6. **Rai is always "Rai"** — exempt from casting. 7. **@copilot is always "@copilot"** — exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name — follow the Copilot Coding Agent Member section instead. -8. Store the mapping in `.squad/casting/registry.json`. -9. Record the assignment snapshot in `.squad/casting/history.json`. +8. Prepare the mapping in `.squad/casting/registry.json`. +9. Record the assignment snapshot in `.squad/casting/history.json` and commit both through the atomic on-disk protocol, including its commit marker. 10. Use the allocated name everywhere: charter.md, history.md, team.md, routing.md, spawn prompts. ### Overflow Handling @@ -755,14 +756,14 @@ Existing agents are NEVER renamed during overflow (only during explicit re-cast) **On-demand reference:** Read `.squad/templates/casting-reference.md` for the full JSON schemas of policy.json, registry.json, and history.json. -The casting system maintains state in `.squad/casting/` with three files: `policy.json` (config), `registry.json` (persistent name registry), and `history.json` (universe usage history + snapshots). +The casting system maintains source-branch configuration in `.squad/casting/`: `policy.json` (policy), `registry.json` (persistent name registry), `history.json` (universe usage history + snapshots), and `registry-history.commit.json` (atomic pair manifest). All remain on disk for every backend. ### Migration — Already-Squadified Repos When `.squad/team.md` exists but `.squad/casting/` does not: 1. **Do NOT rename existing agents.** Mark every existing agent as `legacy_named: true` in the registry. -2. Initialize `.squad/casting/` with default policy.json, a registry.json populated from existing agents, and empty history.json. +2. Initialize `.squad/casting/` with default policy.json and an atomic registry/history pair populated from existing agents. Keep this configuration on the normal source branch. 3. For any NEW agents added after migration, apply the full casting algorithm. 4. Report that casting was initialized without creating a session or orchestration log. diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index 685881668..32eab19d8 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -131,7 +131,9 @@ requirements remain in `.github/PR_REQUIREMENTS.md`. **State-backend handshake — MANDATORY on every session before any state mutation (bradygaster/squad#1305):** -For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/casting/*.json`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes either fail at the pre-commit hook or create phantom state the runtime overwrites at next read — a contract violation that produces silent data loss. +For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes create phantom state outside the configured store. For orphan/two-layer backends, Git ignores decisions, histories, memory and identity; the pre-commit hook rejects staged additions or edits to decisions.md, agent histories, memory and identity. These safeguards do not cover every runtime path or git-notes; the runtime tool contract still applies. + +**Casting ownership:** `.squad/casting/` is static source-branch configuration for every backend, not runtime state. Read and edit policy on disk and commit casting changes on the normal source branch. Registry/history updates must use the atomic casting protocol (`commitCastingRegistryPair`), including its commit marker. Do not write casting through `squad_state_*` or move it onto `squad-state`. The `squad_state_*` and `memory.*` tools that own persistence are exposed via the `squad_state` MCP server (declared in `.mcp.json`). Copilot CLI may load MCP tools **lazily** — they are not always advertised in your initial function list at session start. You MUST proactively confirm they are reachable: @@ -289,13 +291,12 @@ If `memory.*` is not present in the bridge (older Squad versions before the brid - `.squad/decisions.md` - `.squad/decisions/inbox/**` - `.squad/agents/*/history.md` -- `.squad/casting/*.json` - `.squad/identity/*.md` - `.squad/memory/**` - `.squad/rai/audit-trail.md` - `.squad/fact-checker/audit-trail.md` -These are runtime-managed paths under non-local backends. Hand-writing creates phantom state. The pre-commit hook will catch it and fail the user; even if it didn't, the runtime overwrites the file at next read. Report the missing bridge and halt instead. +These are runtime-managed paths under non-local backends. Hand-writing creates phantom state outside the configured store. Git safeguards cover only the subset described above, not every path in this list. Report the missing bridge and halt instead. Casting remains source-branch configuration and is not blocked by a missing runtime bridge. For `STATE_BACKEND ∈ {"local", "worktree"}`, file writes to `.squad/` are valid because the local backend IS the filesystem. @@ -655,7 +656,7 @@ If the user says "I need a designer" or "add someone for DevOps": 1. **Allocate a name** from the current assignment's universe (read from `.squad/casting/history.json`). If the universe is exhausted, apply overflow handling (see Casting & Persistent Naming → Overflow Handling). 2. **Check plugin marketplaces.** If `.squad/plugins/marketplaces.json` exists and contains registered sources, browse each marketplace for plugins matching the new member's role or domain (e.g., "azure-cloud-development" for an Azure DevOps role). Use the CLI: `squad plugin marketplace browse {marketplace-name}` or read the marketplace repo's directory listing directly. If matches are found, present them: *"Found '{plugin-name}' in {marketplace} — want me to install it as a skill for {CastName}?"* If the user accepts, copy the plugin content into `.squad/skills/{plugin-name}/SKILL.md` or merge relevant instructions into the agent's charter. If no marketplaces are configured, skip silently. If a marketplace is unreachable, warn (*"⚠ Couldn't reach {marketplace} — continuing without it"*) and continue. 3. Generate a new charter.md + history.md (seeded with project context from team.md), using the cast name. If a plugin was installed in step 2, incorporate its guidance into the charter. -4. **Update `.squad/casting/registry.json`** with the new agent entry. +4. **Update the casting pair** with the new agent entry using the atomic on-disk protocol described above, not a standalone registry write. 5. Add to team.md roster. 6. Add routing entries to routing.md. 7. Run `squad upgrade` to regenerate Team Capabilities. @@ -667,7 +668,7 @@ If the user wants to remove someone: 1. Move their folder to `.squad/agents/_alumni/{name}/` 2. Remove from team.md roster 3. Update routing.md -4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. +4. **Update the casting pair atomically**: set the registry entry's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. 5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references. 6. Their knowledge is preserved, just inactive. @@ -737,8 +738,8 @@ After selecting a naming mode: 5. **Ralph is always "Ralph"** — exempt from casting. 6. **Rai is always "Rai"** — exempt from casting. 7. **@copilot is always "@copilot"** — exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name — follow the Copilot Coding Agent Member section instead. -8. Store the mapping in `.squad/casting/registry.json`. -9. Record the assignment snapshot in `.squad/casting/history.json`. +8. Prepare the mapping in `.squad/casting/registry.json`. +9. Record the assignment snapshot in `.squad/casting/history.json` and commit both through the atomic on-disk protocol, including its commit marker. 10. Use the allocated name everywhere: charter.md, history.md, team.md, routing.md, spawn prompts. ### Overflow Handling @@ -755,14 +756,14 @@ Existing agents are NEVER renamed during overflow (only during explicit re-cast) **On-demand reference:** Read `.squad/templates/casting-reference.md` for the full JSON schemas of policy.json, registry.json, and history.json. -The casting system maintains state in `.squad/casting/` with three files: `policy.json` (config), `registry.json` (persistent name registry), and `history.json` (universe usage history + snapshots). +The casting system maintains source-branch configuration in `.squad/casting/`: `policy.json` (policy), `registry.json` (persistent name registry), `history.json` (universe usage history + snapshots), and `registry-history.commit.json` (atomic pair manifest). All remain on disk for every backend. ### Migration — Already-Squadified Repos When `.squad/team.md` exists but `.squad/casting/` does not: 1. **Do NOT rename existing agents.** Mark every existing agent as `legacy_named: true` in the registry. -2. Initialize `.squad/casting/` with default policy.json, a registry.json populated from existing agents, and empty history.json. +2. Initialize `.squad/casting/` with default policy.json and an atomic registry/history pair populated from existing agents. Keep this configuration on the normal source branch. 3. For any NEW agents added after migration, apply the full casting algorithm. 4. Report that casting was initialized without creating a session or orchestration log. diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f19b96f..f0694bcf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Stable promotion notes + +- Stable `v1.1.0` predates the following fixes; merging them into `dev` did not publish a fixed stable package. Carry these notes into the next stable release when it is actually promoted. No stable version or publication date is assigned here. +- `squad upgrade --self --dry-run` reports the planned upgrade without installing it. `squad upgrade --state-backend orphan --dry-run` also avoids backend migration, source deletion, config changes and hook installation (#2303, #2295). Available in `v2.0.0-preview.2` and later previews, not stable `v1.1.0`. +- `squad memory` and `memory.*` share the backend-aware governed store (#2317, #2316), so CLI writes and MCP searches use the same storage. Available in `v2.0.0-preview.3`, not stable `v1.1.0`. Existing-memory backend migration is a separate issue (#2425). + ## [2.0.0-preview.3] - 2026-10-29 Third Squad 2.0 preview. npm continues to use `insider` as the canonical dist-tag with `preview` as a compatibility alias; `latest` remains on 1.1.0. Homebrew, WinGet, and stable promotion remain held. Optional built-in roster members remain opt-in through `squad roster add`, and multi-machine watch claims remain off by default. diff --git a/docs/src/content/docs/features/state-backends.md b/docs/src/content/docs/features/state-backends.md index 8d4c7bfbd..d44b11290 100644 --- a/docs/src/content/docs/features/state-backends.md +++ b/docs/src/content/docs/features/state-backends.md @@ -411,6 +411,7 @@ When `stateBackend` is set to `two-layer` or `orphan`, the following templates a **Config vs State distinction:** - **Static config** (charters, team.md, routing.md, casting/) — always on disk, all backends +- Casting policy, registry, history and the atomic pair commit marker belong on the normal source branch. The registry/history pair is written through `commitCastingRegistryPair`, not individual runtime state-tool writes. Casting policy is edited on disk, not through state tools. - **Mutable state** (history.md, decisions/inbox/, logs, orchestration-log/) — backend-dependent The coordinator passes `STATE_BACKEND` into every agent spawn prompt. Agents receive backend-specific instructions for reading and writing state. Scribe receives backend-specific commit instructions. This is fully automatic — no user configuration beyond setting `stateBackend` in config.json is needed. @@ -425,8 +426,8 @@ This will: 1. Update `.squad/config.json` with the new backend 2. Create the `squad-state` orphan branch (if needed) 3. Install git hooks for automatic sync -4. Preserve all existing state -**What happens:** Existing `.squad/` files are migrated to the orphan branch and may be removed from the working tree on subsequent commits. New decisions and state writes go to the orphan branch (and git notes for two-layer). The pre-commit hook prevents you from accidentally re-committing mutable state files into the working tree. +4. Transfer existing decisions, agent histories, decision inbox records and the complete governed `memory/` store, including its array index, configuration, audit archives and tombstones +**What happens:** These files are imported in one orphan-branch commit before configuration switches authority. Unrelated destination files are preserved. Identical destination files are safe to retry; a differing file at the same path (including an index, audit log or config) stops migration with an error for manual reconciliation, without overwriting either copy or changing the backend. Source files are removed only after the transfer and config write succeed. Static casting files remain on disk. Reconnect MCP sessions after migration so they resolve the new backend. This does not migrate transient sessions, scratch or logs. ### Switching between orphan and two-layer Change `stateBackend` in `.squad/config.json`. The coordinator adapts on the next session. Both use the `squad-state` orphan branch, so existing state is preserved. Two-layer additionally enables git notes for commit-scoped annotations. @@ -437,8 +438,9 @@ Once you've migrated to `orphan` or `two-layer`, two additional git hooks enforc Before every commit, this hook scans the staged index for files that belong on the orphan branch: - `.squad/decisions.md` - `.squad/agents/*/history.md` -- `.squad/casting/` -- `.squad/routing/` +- `.squad/memory/` +- `.squad/identity/` +Only additions and edits are blocked; staged deletions of migrated source files are allowed. Casting and routing configuration remain committable on the normal source branch. Memory and identity are also added to the backend-specific ignore block, including when upgrading an existing block. Other runtime paths still rely on their existing ignore rules and the runtime tool contract; this hook is not a blanket guard for all state. If any of those paths are staged, the commit is refused with: ``` ⚠ squad pre-commit: refusing to commit two-layer state into the working tree. @@ -454,11 +456,11 @@ Setting `SQUAD_SYNC_ACTIVE=1` in the environment causes both hooks to exit immed > ⚠️ **Do not use `SQUAD_SYNC_ACTIVE=1` routinely.** Bypassing the pre-commit hook lets state files land in your working branch commits — exactly the situation `two-layer` is designed to prevent. Any PR created from that branch will carry squad state in the diff, defeating the clean-PR promise of the two-layer backend. Use the recovery flow instead. ## Troubleshooting ### "Pre-commit hook refused my commit" -**Cause:** You staged `.squad/` files that belong on the `squad-state` orphan branch (decisions.md, agent histories, casting/, routing/). The pre-commit hook blocks these to keep mutable state off your working-tree branches. +**Cause:** You staged additions or edits to `.squad/` files that belong on the `squad-state` orphan branch (decisions.md, agent histories, memory/, identity/). The pre-commit hook blocks these to keep mutable state off your working-tree branches. **Fix:** ```bash # Unstage the offending paths -git restore --staged .squad/decisions.md .squad/agents/*/history.md .squad/casting/ .squad/routing/ +git restore --staged .squad/decisions.md .squad/agents/*/history.md .squad/memory/ .squad/identity/ # Then commit normally — only your code changes go through git commit ``` diff --git a/packages/squad-cli/src/cli/commands/install-hooks.ts b/packages/squad-cli/src/cli/commands/install-hooks.ts index 591f2545c..fa80ce13d 100644 --- a/packages/squad-cli/src/cli/commands/install-hooks.ts +++ b/packages/squad-cli/src/cli/commands/install-hooks.ts @@ -14,6 +14,7 @@ import { execFileSync } from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; +import { addSquadStateGitignoreBlock, FSStorageProvider } from '@bradygaster/squad-sdk'; const GREEN = '\x1b[32m'; const YELLOW = '\x1b[33m'; @@ -111,11 +112,11 @@ fi ${SQUAD_HOOK_MARKER} # WI-1: Guard against accidentally committing two-layer mutable state into the # working tree. If the user has staged any .squad/ paths that are owned by the -# two-layer/orphan backend (decisions.md, agents/*/history.md, casting/, routing/), +# two-layer/orphan backend (decisions.md, agents/*/history.md, memory/, identity/), # warn and abort so the state stays on the squad-state orphan branch. # Installed by: squad init / squad upgrade --state-backend (two-layer/orphan) if [ -z "$SQUAD_SYNC_ACTIVE" ]; then - STAGED=$(git diff --cached --name-only 2>/dev/null | grep -E '^\\.squad/(decisions\\.md|agents/.+/history\\.md|casting/|routing/)' || true) + STAGED=$(git diff --cached --diff-filter=ACMRT --name-only 2>/dev/null | grep -E '^\\.squad/(decisions\\.md$|agents/[^/]+/history\\.md$|memory/|identity/)' || true) if [ -n "$STAGED" ]; then echo "⚠ squad pre-commit: refusing to commit two-layer state into the working tree." >&2 echo " These paths belong on the 'squad-state' orphan branch, not in your normal commits:" >&2 @@ -128,7 +129,7 @@ fi 'post-commit': `#!/bin/sh ${SQUAD_HOOK_MARKER} # WI-1: After a working-tree commit, sync any pending two-layer state (decisions, -# histories, casting) onto the squad-state orphan branch so team-state stays +# histories, governed memory and identity) onto the squad-state orphan branch so team-state stays # durable and shareable. Best-effort — never blocks the commit. # Installed by: squad init / squad upgrade --state-backend (two-layer/orphan) if [ -z "$SQUAD_SYNC_ACTIVE" ]; then @@ -182,12 +183,14 @@ function installHook(hooksDir: string, hookName: string, content: string, force: // Already has our marker — skip unless force if (existing.includes(SQUAD_HOOK_MARKER)) { if (!force) return 'skipped'; - // Force: remove old squad section and re-append - const cleaned = existing.split('\n').filter(line => { - // Remove lines between markers - return true; // simplified: just replace the file - }).join('\n'); - // For simplicity on force, rewrite with chaining + // Legacy sections end at the unindented outer `fi`. Remove every + // previously appended section, preserving user content on either side. + const cleaned = existing.replace(/^# --- squad-sync-hook ---\r?\n[\s\S]*?^fi(?:\r?\n|$)/gm, '').trimEnd(); + if (cleaned.includes(SQUAD_HOOK_MARKER)) { + throw new Error(`Cannot safely replace squad section in ${hookPath}; repair the hook manually.`); + } + fs.writeFileSync(hookPath, cleaned + '\n\n' + content.split('\n').slice(1).join('\n'), { mode: 0o755 }); + return 'chained'; } // Chain: existing hook runs first, then squad hook (without shebang) @@ -274,6 +277,7 @@ export function ensureHooksForBackend(cwd: string): void { // Only orphan/two-layer need hooks if (backend !== 'orphan' && backend !== 'two-layer') return; + addSquadStateGitignoreBlock(path.join(cwd, '.gitignore'), new FSStorageProvider()); // Check if hooks are already installed let hooksDir: string; @@ -289,10 +293,11 @@ export function ensureHooksForBackend(cwd: string): void { const hookPath = path.join(hooksDir, hookName); if (!fs.existsSync(hookPath)) { allInstalled = false; break; } const content = fs.readFileSync(hookPath, 'utf-8'); - if (!content.includes(SQUAD_HOOK_MARKER)) { allInstalled = false; break; } + if (content.split(SQUAD_HOOK_MARKER).length !== 2 || + !content.includes(HOOK_TEMPLATES[hookName]!.split('\n').slice(1).join('\n'))) { allInstalled = false; break; } } if (allInstalled) return; // Hooks missing — install them - installGitHooks(cwd, { force: false }); + installGitHooks(cwd, { force: true }); } diff --git a/packages/squad-cli/src/cli/commands/migrate-backend.ts b/packages/squad-cli/src/cli/commands/migrate-backend.ts index ac0831d69..d08a9883e 100644 --- a/packages/squad-cli/src/cli/commands/migrate-backend.ts +++ b/packages/squad-cli/src/cli/commands/migrate-backend.ts @@ -10,7 +10,7 @@ * * In addition to flipping the `stateBackend` key in `.squad/config.json`, this * function MIGRATES pre-existing working-tree state (`decisions.md`, - * `agents//history.md`) onto the squad-state orphan branch when moving + * `agents//history.md`, governed memory and decision inbox records) onto the squad-state orphan branch when moving * from a working-tree backend to an orphan-storage backend, so post-upgrade * agents can read pre-upgrade content (UPGRADE-NO-MIGRATION fix). */ @@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; -import { installGitHooks } from './install-hooks.js'; +import { installGitHooks, ensureHooksForBackend } from './install-hooks.js'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { addSquadStateGitignoreBlock, removeSquadStateGitignoreBlock } from '@bradygaster/squad-sdk'; @@ -31,6 +31,7 @@ const RESET = '\x1b[0m'; const VALID_TARGETS = ['local', 'worktree', 'orphan', 'two-layer']; const ORPHAN_BACKENDS = new Set(['orphan', 'two-layer']); const WORKTREE_BACKENDS = new Set(['local', 'worktree']); +type StateFile = { relPath: string; content: Buffer }; /** Paths inside .squad/ that should be carried over to the orphan branch on migration. */ const MIGRATABLE_PATHS = [ @@ -57,7 +58,7 @@ export function ensureOrphanBranch(dest: string): boolean { const commitHash = execFileSync('git', ['commit-tree', treeHash, '-m', 'init: squad-state orphan branch'], { cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); - execFileSync('git', ['update-ref', 'refs/heads/squad-state', commitHash], { + execFileSync('git', ['update-ref', 'refs/heads/squad-state', commitHash, '0000000000000000000000000000000000000000'], { cwd: dest, stdio: ['pipe', 'pipe', 'pipe'], }); return true; @@ -72,14 +73,14 @@ export function ensureOrphanBranch(dest: string): boolean { * Collect existing working-tree state files that should be migrated to the orphan branch. * Returns array of {path, content} pairs (paths are relative to .squad/). */ -function collectWorktreeState(dest: string): Array<{ relPath: string; content: string }> { +function collectWorktreeState(dest: string): StateFile[] { const squadDir = path.join(dest, '.squad'); - const collected: Array<{ relPath: string; content: string }> = []; + const collected: StateFile[] = []; for (const p of MIGRATABLE_PATHS) { const full = path.join(squadDir, p); if (fs.existsSync(full) && fs.statSync(full).isFile()) { - collected.push({ relPath: p, content: fs.readFileSync(full, 'utf-8') }); + collected.push({ relPath: p, content: fs.readFileSync(full) }); } } @@ -91,39 +92,75 @@ function collectWorktreeState(dest: string): Array<{ relPath: string; content: s if (fs.existsSync(histPath) && fs.statSync(histPath).isFile()) { collected.push({ relPath: path.posix.join('agents', agentName, 'history.md'), - content: fs.readFileSync(histPath, 'utf-8'), + content: fs.readFileSync(histPath), }); } } } + // DECISION memories live outside memory/, in decisions/inbox. + const collectDirectory = (relative: string): void => { + const full = path.join(squadDir, relative); + if (!fs.existsSync(full)) return; + if (fs.lstatSync(full).isSymbolicLink()) { + throw new Error(`Cannot migrate symbolic link .squad/${relative}; source was retained.`); + } + if (!fs.statSync(full).isDirectory()) { + throw new Error(`Expected .squad/${relative} to be a directory; source was retained.`); + } + for (const entry of fs.readdirSync(full, { withFileTypes: true })) { + const relPath = path.posix.join(relative, entry.name); + if (entry.isDirectory()) collectDirectory(relPath); + else if (entry.isFile()) collected.push({ relPath, content: fs.readFileSync(path.join(squadDir, relPath)) }); + else throw new Error(`Cannot migrate non-regular file .squad/${relPath}; source was retained.`); + } + }; + collectDirectory('memory'); + collectDirectory('decisions/inbox'); + return collected; } /** * Write a set of files onto the squad-state orphan branch via git plumbing. * Preserves any files already on the branch (merges trees by path). - * Returns the number of files written, or -1 on failure. + * Returns the number imported (including identical files); throws on failure. */ -function writeFilesToOrphanBranch(dest: string, files: Array<{ relPath: string; content: string }>): number { +function writeFilesToOrphanBranch(dest: string, files: StateFile[]): number { if (files.length === 0) return 0; + let indexDir: string | undefined; try { // Load the current squad-state tree into the index using a temporary index file const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); - const indexFile = path.resolve(dest, gitDir, 'squad-migrate-index'); - if (fs.existsSync(indexFile)) fs.unlinkSync(indexFile); + indexDir = fs.mkdtempSync(path.resolve(dest, gitDir, 'squad-migrate-')); + const indexFile = path.join(indexDir, 'index'); const env = { ...process.env, GIT_INDEX_FILE: indexFile }; + const parentSha = execFileSync('git', ['rev-parse', 'refs/heads/squad-state'], { + cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + const existing = new Set(execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', parentSha], { + cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).split('\0').filter(Boolean)); // Seed index with existing squad-state tree - execFileSync('git', ['read-tree', 'refs/heads/squad-state'], { + execFileSync('git', ['read-tree', parentSha], { cwd: dest, env, stdio: ['pipe', 'pipe', 'pipe'], }); // Add each migrated file for (const f of files) { + if (existing.has(f.relPath)) { + const content = execFileSync('git', ['show', `${parentSha}:${f.relPath}`], { + cwd: dest, stdio: ['pipe', 'pipe', 'pipe'], + }); + if (!content.equals(f.content)) { + throw new Error(`Conflicting state at ${f.relPath}. Reconcile the source and squad-state copies before retrying; neither copy was changed.`); + } + continue; + } const blobHash = execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: dest, env, encoding: 'utf-8', input: f.content, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); @@ -136,9 +173,10 @@ function writeFilesToOrphanBranch(dest: string, files: Array<{ relPath: string; cwd: dest, env, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); - const parentSha = execFileSync('git', ['rev-parse', 'refs/heads/squad-state'], { + const parentTree = execFileSync('git', ['rev-parse', `${parentSha}^{tree}`], { cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); + if (treeHash === parentTree) return files.length; const commitHash = execFileSync( 'git', @@ -150,13 +188,11 @@ function writeFilesToOrphanBranch(dest: string, files: Array<{ relPath: string; cwd: dest, stdio: ['pipe', 'pipe', 'pipe'], }); - // Cleanup the temporary index - if (fs.existsSync(indexFile)) fs.unlinkSync(indexFile); - return files.length; } catch (err) { - console.log(`${YELLOW}⚠ Could not migrate state onto squad-state branch: ${err instanceof Error ? err.message : err}${RESET}`); - return -1; + throw new Error(`Could not migrate state onto squad-state branch: ${err instanceof Error ? err.message : err}`, { cause: err }); + } finally { + if (indexDir) fs.rmSync(indexDir, { recursive: true, force: true }); } } @@ -182,17 +218,27 @@ export async function migrateStateBackend(dest: string, target: string): Promise try { if (fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Expected a config object.'); + } + config = parsed as Record; } - } catch { /* start fresh */ } + } catch (err) { + throw new Error('Cannot read squad config; backend migration was not started.', { cause: err }); + } - const current = (config['stateBackend'] as string) || 'local'; + const current = config['stateBackend'] ?? 'local'; + if (typeof current !== 'string' || !VALID_TARGETS.includes(current)) { + throw new Error(`Unsupported source backend '${String(current)}'; backend was not switched.`); + } if (current === target) { console.log(`${YELLOW}⚠ Backend is already '${target}'. Ensuring hooks + config are consistent.${RESET}`); if (ORPHAN_BACKENDS.has(target)) { - ensureOrphanBranch(dest); - installGitHooks(dest, { force: false }); + if (!ensureOrphanBranch(dest)) throw new Error('Cannot initialize squad-state; backend was not switched.'); + ensureHooksForBackend(dest); + addSquadStateGitignoreBlock(path.join(dest, '.gitignore'), new FSStorageProvider()); } return; } @@ -201,10 +247,11 @@ export async function migrateStateBackend(dest: string, target: string): Promise // Step 1: Ensure orphan branch exists when the target needs it if (ORPHAN_BACKENDS.has(target)) { - if (!ensureOrphanBranch(dest)) return; + if (!ensureOrphanBranch(dest)) throw new Error('Cannot initialize squad-state; backend was not switched.'); console.log(` ${GREEN}✓${RESET} squad-state branch ready`); } + let migratedFiles: StateFile[] = []; // Step 2 (UPGRADE-NO-MIGRATION): move working-tree state onto the orphan branch // when transitioning from a worktree backend to an orphan-storage backend. if (WORKTREE_BACKENDS.has(current) && ORPHAN_BACKENDS.has(target)) { @@ -212,71 +259,77 @@ export async function migrateStateBackend(dest: string, target: string): Promise if (files.length > 0) { const wrote = writeFilesToOrphanBranch(dest, files); if (wrote > 0) { + migratedFiles = files; console.log(` ${GREEN}✓${RESET} migrated ${wrote} state file(s) onto squad-state branch:`); for (const f of files) { console.log(` ${DIM}.squad/${f.relPath}${RESET}`); } - // F1 fix (Round 5, P1.1): the working-tree copies are now stale — the - // orphan branch is the source of truth for an orphan/two-layer backend. - // Remove them so `git status` is clean and post-upgrade agents can't - // accidentally read stale content. Mirrors liftInitMutableStateOntoOrphan - // behavior. Only files we just successfully migrated are removed; we - // never touch config.json, charter.md, team.md, casting/, templates/, etc. - const squadDir = path.join(dest, '.squad'); - const removed: string[] = []; - const removeFailed: string[] = []; - for (const f of files) { - const full = path.join(squadDir, f.relPath); - try { - if (fs.existsSync(full)) fs.unlinkSync(full); - removed.push(f.relPath); - } catch { - removeFailed.push(f.relPath); - } - } - // Clean up now-empty agent directories (e.g. .squad/agents/data/) so - // they don't leak as zero-content folders post-upgrade. - const agentsDir = path.join(squadDir, 'agents'); - if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) { - for (const agentName of fs.readdirSync(agentsDir)) { - const agentDir = path.join(agentsDir, agentName); - try { - if ( - fs.statSync(agentDir).isDirectory() && - fs.readdirSync(agentDir).length === 0 - ) { - fs.rmdirSync(agentDir); - } - } catch { /* best-effort */ } - } - } - - if (removed.length > 0) { - console.log(` ${GREEN}✓${RESET} removed ${removed.length} stale working-tree file(s) (now sourced from squad-state):`); - for (const r of removed) { - console.log(` ${DIM}.squad/${r}${RESET}`); - } - } - if (removeFailed.length > 0) { - console.log(` ${YELLOW}⚠${RESET} could not remove: ${removeFailed.join(', ')} (will appear in git status)`); - } - } else if (wrote === 0) { + } else { console.log(` ${DIM}no working-tree state files to migrate${RESET}`); } - // If wrote === -1 we already printed a warning; continue so config still updates. } else { console.log(` ${DIM}no migratable state in working tree${RESET}`); } } - // Step 3 (UPGRADE-FLAG-IGNORED + Bug E): JSON-merge the new value. - // Reading + re-stringifying guarantees one canonical `stateBackend` key. + // Recheck the snapshot before switching authority. A retry can reuse the + // identical imported tree after a failed config write without losing source. + if (WORKTREE_BACKENDS.has(current) && ORPHAN_BACKENDS.has(target)) { + const snapshot = new Map(collectWorktreeState(dest).map(file => [file.relPath, file.content])); + if (snapshot.size !== migratedFiles.length || migratedFiles.some(file => !snapshot.get(file.relPath)?.equals(file.content))) { + throw new Error('Source changed during migration. Backend was not switched; reconcile before retrying.'); + } + } config['stateBackend'] = target; fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + const configTempDir = fs.mkdtempSync(path.join(path.dirname(configPath), '.config-migrate-')); + try { + const configTempPath = path.join(configTempDir, 'config.json'); + fs.writeFileSync(configTempPath, JSON.stringify(config, null, 2) + '\n'); + fs.renameSync(configTempPath, configPath); + } finally { + fs.rmSync(configTempDir, { recursive: true, force: true }); + } console.log(` ${GREEN}✓${RESET} config.json updated: stateBackend = ${target}`); + if (migratedFiles.length > 0) { + // Only remove the imported snapshot, never a concurrent local edit. + const squadDir = path.join(dest, '.squad'); + const removed: string[] = []; + const removeFailed: string[] = []; + for (const f of migratedFiles) { + const full = path.join(squadDir, f.relPath); + try { + if (fs.existsSync(full)) { + if (!fs.readFileSync(full).equals(f.content)) throw new Error('Source changed after migration'); + fs.unlinkSync(full); + } + removed.push(f.relPath); + } catch { + removeFailed.push(f.relPath); + } + } + const agentsDir = path.join(squadDir, 'agents'); + if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) { + for (const agentName of fs.readdirSync(agentsDir)) { + const agentDir = path.join(agentsDir, agentName); + try { + if (fs.statSync(agentDir).isDirectory() && fs.readdirSync(agentDir).length === 0) { + fs.rmdirSync(agentDir); + } + } catch { /* best-effort empty-directory cleanup */ } + } + } + if (removed.length > 0) { + console.log(` ${GREEN}✓${RESET} removed ${removed.length} stale working-tree file(s) (now sourced from squad-state):`); + for (const r of removed) console.log(` ${DIM}.squad/${r}${RESET}`); + } + if (removeFailed.length > 0) { + console.log(` ${YELLOW}⚠${RESET} source retained (changed or could not be removed): ${removeFailed.join(', ')}. Reconcile these files with squad-state.`); + } + } + // Step 4 (WI-1): re-install hooks (force so new pre-commit/post-commit land) if (ORPHAN_BACKENDS.has(target)) { installGitHooks(dest, { force: true }); @@ -289,17 +342,17 @@ export async function migrateStateBackend(dest: string, target: string): Promise // files are committable again. const gitignorePath = path.join(dest, '.gitignore'); const storage = new FSStorageProvider(); - if (ORPHAN_BACKENDS.has(target) && !ORPHAN_BACKENDS.has(current)) { - // Transitioning FROM a worktree backend TO orphan/two-layer — add block + if (ORPHAN_BACKENDS.has(target)) { + // Also upgrade older marker blocks during orphan/two-layer transitions. const added = addSquadStateGitignoreBlock(gitignorePath, storage); if (added) { - console.log(` ${GREEN}✓${RESET} added 2 entries to .gitignore (.squad/decisions.md, .squad/agents/*/history.md) — these now live on squad-state branch`); + console.log(` ${GREEN}✓${RESET} updated .gitignore protections for decisions, histories, memory and identity`); } } else if (WORKTREE_BACKENDS.has(target) && ORPHAN_BACKENDS.has(current)) { // Transitioning FROM orphan/two-layer BACK TO a local backend — remove block const removed = removeSquadStateGitignoreBlock(gitignorePath, storage); if (removed) { - console.log(` ${GREEN}✓${RESET} removed 2 entries from .gitignore — .squad/decisions.md and agent histories are now committable again`); + console.log(` ${GREEN}✓${RESET} removed squad-state .gitignore block — local state is committable again`); } } @@ -317,7 +370,7 @@ export async function migrateStateBackend(dest: string, target: string): Promise * * Source-of-truth hierarchy preserved: static files (team.md, charters, * ceremonies.md, casting/*, templates/*) are NEVER touched — only mutable - * state (decisions.md, agents//history.md) migrates. + * state (decisions.md, agents//history.md, memory/ and decisions/inbox/) migrates. * * Returns the relative paths of files that were migrated + removed. */ @@ -331,10 +384,13 @@ export function liftInitMutableStateOntoOrphan(dest: string): string[] { for (const f of files) { const full = path.join(squadDir, f.relPath); try { - if (fs.existsSync(full)) fs.unlinkSync(full); + if (fs.existsSync(full)) { + if (!fs.readFileSync(full).equals(f.content)) throw new Error('Source changed after import'); + fs.unlinkSync(full); + } removed.push(f.relPath); - } catch { - // Leave file behind rather than aborting; the runtime bridge already has authoritative copy. + } catch (err) { + console.warn(`Could not remove imported .squad/${f.relPath}; source was retained: ${err instanceof Error ? err.message : err}`); } } return removed; diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 685881668..32eab19d8 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -131,7 +131,9 @@ requirements remain in `.github/PR_REQUIREMENTS.md`. **State-backend handshake — MANDATORY on every session before any state mutation (bradygaster/squad#1305):** -For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/casting/*.json`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes either fail at the pre-commit hook or create phantom state the runtime overwrites at next read — a contract violation that produces silent data loss. +For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes create phantom state outside the configured store. For orphan/two-layer backends, Git ignores decisions, histories, memory and identity; the pre-commit hook rejects staged additions or edits to decisions.md, agent histories, memory and identity. These safeguards do not cover every runtime path or git-notes; the runtime tool contract still applies. + +**Casting ownership:** `.squad/casting/` is static source-branch configuration for every backend, not runtime state. Read and edit policy on disk and commit casting changes on the normal source branch. Registry/history updates must use the atomic casting protocol (`commitCastingRegistryPair`), including its commit marker. Do not write casting through `squad_state_*` or move it onto `squad-state`. The `squad_state_*` and `memory.*` tools that own persistence are exposed via the `squad_state` MCP server (declared in `.mcp.json`). Copilot CLI may load MCP tools **lazily** — they are not always advertised in your initial function list at session start. You MUST proactively confirm they are reachable: @@ -289,13 +291,12 @@ If `memory.*` is not present in the bridge (older Squad versions before the brid - `.squad/decisions.md` - `.squad/decisions/inbox/**` - `.squad/agents/*/history.md` -- `.squad/casting/*.json` - `.squad/identity/*.md` - `.squad/memory/**` - `.squad/rai/audit-trail.md` - `.squad/fact-checker/audit-trail.md` -These are runtime-managed paths under non-local backends. Hand-writing creates phantom state. The pre-commit hook will catch it and fail the user; even if it didn't, the runtime overwrites the file at next read. Report the missing bridge and halt instead. +These are runtime-managed paths under non-local backends. Hand-writing creates phantom state outside the configured store. Git safeguards cover only the subset described above, not every path in this list. Report the missing bridge and halt instead. Casting remains source-branch configuration and is not blocked by a missing runtime bridge. For `STATE_BACKEND ∈ {"local", "worktree"}`, file writes to `.squad/` are valid because the local backend IS the filesystem. @@ -655,7 +656,7 @@ If the user says "I need a designer" or "add someone for DevOps": 1. **Allocate a name** from the current assignment's universe (read from `.squad/casting/history.json`). If the universe is exhausted, apply overflow handling (see Casting & Persistent Naming → Overflow Handling). 2. **Check plugin marketplaces.** If `.squad/plugins/marketplaces.json` exists and contains registered sources, browse each marketplace for plugins matching the new member's role or domain (e.g., "azure-cloud-development" for an Azure DevOps role). Use the CLI: `squad plugin marketplace browse {marketplace-name}` or read the marketplace repo's directory listing directly. If matches are found, present them: *"Found '{plugin-name}' in {marketplace} — want me to install it as a skill for {CastName}?"* If the user accepts, copy the plugin content into `.squad/skills/{plugin-name}/SKILL.md` or merge relevant instructions into the agent's charter. If no marketplaces are configured, skip silently. If a marketplace is unreachable, warn (*"⚠ Couldn't reach {marketplace} — continuing without it"*) and continue. 3. Generate a new charter.md + history.md (seeded with project context from team.md), using the cast name. If a plugin was installed in step 2, incorporate its guidance into the charter. -4. **Update `.squad/casting/registry.json`** with the new agent entry. +4. **Update the casting pair** with the new agent entry using the atomic on-disk protocol described above, not a standalone registry write. 5. Add to team.md roster. 6. Add routing entries to routing.md. 7. Run `squad upgrade` to regenerate Team Capabilities. @@ -667,7 +668,7 @@ If the user wants to remove someone: 1. Move their folder to `.squad/agents/_alumni/{name}/` 2. Remove from team.md roster 3. Update routing.md -4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. +4. **Update the casting pair atomically**: set the registry entry's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. 5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references. 6. Their knowledge is preserved, just inactive. @@ -737,8 +738,8 @@ After selecting a naming mode: 5. **Ralph is always "Ralph"** — exempt from casting. 6. **Rai is always "Rai"** — exempt from casting. 7. **@copilot is always "@copilot"** — exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name — follow the Copilot Coding Agent Member section instead. -8. Store the mapping in `.squad/casting/registry.json`. -9. Record the assignment snapshot in `.squad/casting/history.json`. +8. Prepare the mapping in `.squad/casting/registry.json`. +9. Record the assignment snapshot in `.squad/casting/history.json` and commit both through the atomic on-disk protocol, including its commit marker. 10. Use the allocated name everywhere: charter.md, history.md, team.md, routing.md, spawn prompts. ### Overflow Handling @@ -755,14 +756,14 @@ Existing agents are NEVER renamed during overflow (only during explicit re-cast) **On-demand reference:** Read `.squad/templates/casting-reference.md` for the full JSON schemas of policy.json, registry.json, and history.json. -The casting system maintains state in `.squad/casting/` with three files: `policy.json` (config), `registry.json` (persistent name registry), and `history.json` (universe usage history + snapshots). +The casting system maintains source-branch configuration in `.squad/casting/`: `policy.json` (policy), `registry.json` (persistent name registry), `history.json` (universe usage history + snapshots), and `registry-history.commit.json` (atomic pair manifest). All remain on disk for every backend. ### Migration — Already-Squadified Repos When `.squad/team.md` exists but `.squad/casting/` does not: 1. **Do NOT rename existing agents.** Mark every existing agent as `legacy_named: true` in the registry. -2. Initialize `.squad/casting/` with default policy.json, a registry.json populated from existing agents, and empty history.json. +2. Initialize `.squad/casting/` with default policy.json and an atomic registry/history pair populated from existing agents. Keep this configuration on the normal source branch. 3. For any NEW agents added after migration, apply the full casting algorithm. 4. Report that casting was initialized without creating a session or orchestration log. diff --git a/packages/squad-sdk/src/config/gitignore-state.ts b/packages/squad-sdk/src/config/gitignore-state.ts index 155d6ae36..1ae2dc8b6 100644 --- a/packages/squad-sdk/src/config/gitignore-state.ts +++ b/packages/squad-sdk/src/config/gitignore-state.ts @@ -2,7 +2,7 @@ * Helpers for managing the squad-state .gitignore marker block. * * When the state backend is 'two-layer' or 'orphan', .squad/decisions.md - * and .squad/agents/history.md are owned by the squad-state orphan branch. + * and .squad/agents//history.md, memory/ and identity/ are owned by the squad-state branch. * Adding them to .gitignore prevents accidental staging via git add ., * git add -A, IDE "stage all", or git commit -am -- defense-in-depth * complement to the pre-commit hook installed by squad upgrade --state-backend. @@ -22,13 +22,15 @@ const SQUAD_STATE_GITIGNORE_BLOCK = SQUAD_STATE_GITIGNORE_OPEN_MARKER + '\n' + '.squad/decisions.md\n' + '.squad/agents/*/history.md\n' + + '.squad/memory/\n' + + '.squad/identity/\n' + SQUAD_STATE_GITIGNORE_CLOSE_MARKER + '\n'; /** * Append the squad-state marker block to .gitignore if not already present. * - * Idempotent: if the opening marker already exists in the file, this is a - * no-op. Returns true if the block was added, false if it was already present. + * Idempotently upgrades older blocks to include memory and identity. + * Returns true if the block was added or upgraded. */ export function addSquadStateGitignoreBlock( gitignorePath: string, @@ -40,7 +42,15 @@ export function addSquadStateGitignoreBlock( } if (content.includes(SQUAD_STATE_GITIGNORE_OPEN_MARKER)) { - return false; + const lines = content.split('\n'); + const start = lines.findIndex(line => line.trimEnd() === SQUAD_STATE_GITIGNORE_OPEN_MARKER); + const end = lines.findIndex((line, index) => index > start && line.trimEnd() === SQUAD_STATE_GITIGNORE_CLOSE_MARKER); + if (start < 0 || end < 0) throw new Error('Malformed squad-state .gitignore block; repair its markers before retrying.'); + const missing = ['.squad/memory/', '.squad/identity/'].filter(entry => !lines.slice(start + 1, end).some(line => line.trimEnd() === entry)); + if (missing.length === 0) return false; + lines.splice(end, 0, ...missing); + storage.writeSync(gitignorePath, lines.join('\n')); + return true; } const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; @@ -69,20 +79,20 @@ export function removeSquadStateGitignoreBlock( } const lines = content.split('\n'); - const openIdx = lines.findIndex((l) => l === SQUAD_STATE_GITIGNORE_OPEN_MARKER); + const openIdx = lines.findIndex((l) => l.trimEnd() === SQUAD_STATE_GITIGNORE_OPEN_MARKER); if (openIdx === -1) return false; // Find the closing marker starting from the opening marker let closeIdx = -1; for (let i = openIdx + 1; i < lines.length; i++) { - if (lines[i] === SQUAD_STATE_GITIGNORE_CLOSE_MARKER) { + if (lines[i]!.trimEnd() === SQUAD_STATE_GITIGNORE_CLOSE_MARKER) { closeIdx = i; break; } } - // If no closing marker found, remove from opening marker to end - const endIdx = closeIdx !== -1 ? closeIdx : lines.length - 1; + if (closeIdx === -1) throw new Error('Malformed squad-state .gitignore block; repair its markers before retrying.'); + const endIdx = closeIdx; const before = lines.slice(0, openIdx); const after = lines.slice(endIdx + 1); diff --git a/packages/squad-sdk/src/tools/index.ts b/packages/squad-sdk/src/tools/index.ts index 4cba651bc..87c37dcef 100644 --- a/packages/squad-sdk/src/tools/index.ts +++ b/packages/squad-sdk/src/tools/index.ts @@ -313,7 +313,6 @@ export const MUTABLE_STATE_PATHS: ReadonlyArray<{ readonly root: string; readonl // Per-workstream decision inboxes (`.squad/workstreams/active//decisions/inbox/`), the // convention squad_decide targets when a workstream is active (#2299). { root: 'workstreams', kind: 'dir', pattern: /^workstreams\/active\/[A-Za-z0-9._-]+\/decisions\/inbox\/[^/]+$/ }, - { root: 'casting/policy.json', kind: 'file' }, { root: 'agents', kind: 'dir', pattern: /^agents\/[a-zA-Z0-9_-]+\/history\.md$/ }, { root: 'log', kind: 'dir' }, { root: 'orchestration-log', kind: 'dir' }, @@ -377,7 +376,7 @@ function validateMutableStateToolKey(key: string, operation: 'write' | 'append' } if (!isMutableStateKey(key)) { throw new Error( - 'State mutations are limited to mutable runtime state (decisions, inbox, casting policy, logs, sessions, scratch files, agent history, and identity). The casting registry/history pair must only be changed through the atomic casting protocol. Static config such as config.json, team.md, routing.md, charters, templates, and skills must not be changed with state tools.', + 'State mutations are limited to mutable runtime state (decisions, inbox, logs, sessions, scratch files, agent history, and identity). Casting is source-branch configuration; the casting registry/history pair must only be changed through the atomic casting protocol on disk. Static config such as casting policy, config.json, team.md, routing.md, charters, templates, and skills must not be changed with state tools.', ); } } diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 685881668..32eab19d8 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -131,7 +131,9 @@ requirements remain in `.github/PR_REQUIREMENTS.md`. **State-backend handshake — MANDATORY on every session before any state mutation (bradygaster/squad#1305):** -For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/casting/*.json`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes either fail at the pre-commit hook or create phantom state the runtime overwrites at next read — a contract violation that produces silent data loss. +For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes create phantom state outside the configured store. For orphan/two-layer backends, Git ignores decisions, histories, memory and identity; the pre-commit hook rejects staged additions or edits to decisions.md, agent histories, memory and identity. These safeguards do not cover every runtime path or git-notes; the runtime tool contract still applies. + +**Casting ownership:** `.squad/casting/` is static source-branch configuration for every backend, not runtime state. Read and edit policy on disk and commit casting changes on the normal source branch. Registry/history updates must use the atomic casting protocol (`commitCastingRegistryPair`), including its commit marker. Do not write casting through `squad_state_*` or move it onto `squad-state`. The `squad_state_*` and `memory.*` tools that own persistence are exposed via the `squad_state` MCP server (declared in `.mcp.json`). Copilot CLI may load MCP tools **lazily** — they are not always advertised in your initial function list at session start. You MUST proactively confirm they are reachable: @@ -289,13 +291,12 @@ If `memory.*` is not present in the bridge (older Squad versions before the brid - `.squad/decisions.md` - `.squad/decisions/inbox/**` - `.squad/agents/*/history.md` -- `.squad/casting/*.json` - `.squad/identity/*.md` - `.squad/memory/**` - `.squad/rai/audit-trail.md` - `.squad/fact-checker/audit-trail.md` -These are runtime-managed paths under non-local backends. Hand-writing creates phantom state. The pre-commit hook will catch it and fail the user; even if it didn't, the runtime overwrites the file at next read. Report the missing bridge and halt instead. +These are runtime-managed paths under non-local backends. Hand-writing creates phantom state outside the configured store. Git safeguards cover only the subset described above, not every path in this list. Report the missing bridge and halt instead. Casting remains source-branch configuration and is not blocked by a missing runtime bridge. For `STATE_BACKEND ∈ {"local", "worktree"}`, file writes to `.squad/` are valid because the local backend IS the filesystem. @@ -655,7 +656,7 @@ If the user says "I need a designer" or "add someone for DevOps": 1. **Allocate a name** from the current assignment's universe (read from `.squad/casting/history.json`). If the universe is exhausted, apply overflow handling (see Casting & Persistent Naming → Overflow Handling). 2. **Check plugin marketplaces.** If `.squad/plugins/marketplaces.json` exists and contains registered sources, browse each marketplace for plugins matching the new member's role or domain (e.g., "azure-cloud-development" for an Azure DevOps role). Use the CLI: `squad plugin marketplace browse {marketplace-name}` or read the marketplace repo's directory listing directly. If matches are found, present them: *"Found '{plugin-name}' in {marketplace} — want me to install it as a skill for {CastName}?"* If the user accepts, copy the plugin content into `.squad/skills/{plugin-name}/SKILL.md` or merge relevant instructions into the agent's charter. If no marketplaces are configured, skip silently. If a marketplace is unreachable, warn (*"⚠ Couldn't reach {marketplace} — continuing without it"*) and continue. 3. Generate a new charter.md + history.md (seeded with project context from team.md), using the cast name. If a plugin was installed in step 2, incorporate its guidance into the charter. -4. **Update `.squad/casting/registry.json`** with the new agent entry. +4. **Update the casting pair** with the new agent entry using the atomic on-disk protocol described above, not a standalone registry write. 5. Add to team.md roster. 6. Add routing entries to routing.md. 7. Run `squad upgrade` to regenerate Team Capabilities. @@ -667,7 +668,7 @@ If the user wants to remove someone: 1. Move their folder to `.squad/agents/_alumni/{name}/` 2. Remove from team.md roster 3. Update routing.md -4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. +4. **Update the casting pair atomically**: set the registry entry's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. 5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references. 6. Their knowledge is preserved, just inactive. @@ -737,8 +738,8 @@ After selecting a naming mode: 5. **Ralph is always "Ralph"** — exempt from casting. 6. **Rai is always "Rai"** — exempt from casting. 7. **@copilot is always "@copilot"** — exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name — follow the Copilot Coding Agent Member section instead. -8. Store the mapping in `.squad/casting/registry.json`. -9. Record the assignment snapshot in `.squad/casting/history.json`. +8. Prepare the mapping in `.squad/casting/registry.json`. +9. Record the assignment snapshot in `.squad/casting/history.json` and commit both through the atomic on-disk protocol, including its commit marker. 10. Use the allocated name everywhere: charter.md, history.md, team.md, routing.md, spawn prompts. ### Overflow Handling @@ -755,14 +756,14 @@ Existing agents are NEVER renamed during overflow (only during explicit re-cast) **On-demand reference:** Read `.squad/templates/casting-reference.md` for the full JSON schemas of policy.json, registry.json, and history.json. -The casting system maintains state in `.squad/casting/` with three files: `policy.json` (config), `registry.json` (persistent name registry), and `history.json` (universe usage history + snapshots). +The casting system maintains source-branch configuration in `.squad/casting/`: `policy.json` (policy), `registry.json` (persistent name registry), `history.json` (universe usage history + snapshots), and `registry-history.commit.json` (atomic pair manifest). All remain on disk for every backend. ### Migration — Already-Squadified Repos When `.squad/team.md` exists but `.squad/casting/` does not: 1. **Do NOT rename existing agents.** Mark every existing agent as `legacy_named: true` in the registry. -2. Initialize `.squad/casting/` with default policy.json, a registry.json populated from existing agents, and empty history.json. +2. Initialize `.squad/casting/` with default policy.json and an atomic registry/history pair populated from existing agents. Keep this configuration on the normal source branch. 3. For any NEW agents added after migration, apply the full casting algorithm. 4. Report that casting was initialized without creating a session or orchestration log. diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 685881668..32eab19d8 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -131,7 +131,9 @@ requirements remain in `.github/PR_REQUIREMENTS.md`. **State-backend handshake — MANDATORY on every session before any state mutation (bradygaster/squad#1305):** -For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/casting/*.json`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes either fail at the pre-commit hook or create phantom state the runtime overwrites at next read — a contract violation that produces silent data loss. +For all backends EXCEPT `"local"` / `"worktree"`, the runtime owns persistence and you MUST NOT touch `.squad/decisions.md`, `.squad/decisions/inbox/`, `.squad/agents/*/history.md`, `.squad/identity/*.md`, or `.squad/memory/*` paths via `create` / `edit` / `write_file` tools. Those writes create phantom state outside the configured store. For orphan/two-layer backends, Git ignores decisions, histories, memory and identity; the pre-commit hook rejects staged additions or edits to decisions.md, agent histories, memory and identity. These safeguards do not cover every runtime path or git-notes; the runtime tool contract still applies. + +**Casting ownership:** `.squad/casting/` is static source-branch configuration for every backend, not runtime state. Read and edit policy on disk and commit casting changes on the normal source branch. Registry/history updates must use the atomic casting protocol (`commitCastingRegistryPair`), including its commit marker. Do not write casting through `squad_state_*` or move it onto `squad-state`. The `squad_state_*` and `memory.*` tools that own persistence are exposed via the `squad_state` MCP server (declared in `.mcp.json`). Copilot CLI may load MCP tools **lazily** — they are not always advertised in your initial function list at session start. You MUST proactively confirm they are reachable: @@ -289,13 +291,12 @@ If `memory.*` is not present in the bridge (older Squad versions before the brid - `.squad/decisions.md` - `.squad/decisions/inbox/**` - `.squad/agents/*/history.md` -- `.squad/casting/*.json` - `.squad/identity/*.md` - `.squad/memory/**` - `.squad/rai/audit-trail.md` - `.squad/fact-checker/audit-trail.md` -These are runtime-managed paths under non-local backends. Hand-writing creates phantom state. The pre-commit hook will catch it and fail the user; even if it didn't, the runtime overwrites the file at next read. Report the missing bridge and halt instead. +These are runtime-managed paths under non-local backends. Hand-writing creates phantom state outside the configured store. Git safeguards cover only the subset described above, not every path in this list. Report the missing bridge and halt instead. Casting remains source-branch configuration and is not blocked by a missing runtime bridge. For `STATE_BACKEND ∈ {"local", "worktree"}`, file writes to `.squad/` are valid because the local backend IS the filesystem. @@ -655,7 +656,7 @@ If the user says "I need a designer" or "add someone for DevOps": 1. **Allocate a name** from the current assignment's universe (read from `.squad/casting/history.json`). If the universe is exhausted, apply overflow handling (see Casting & Persistent Naming → Overflow Handling). 2. **Check plugin marketplaces.** If `.squad/plugins/marketplaces.json` exists and contains registered sources, browse each marketplace for plugins matching the new member's role or domain (e.g., "azure-cloud-development" for an Azure DevOps role). Use the CLI: `squad plugin marketplace browse {marketplace-name}` or read the marketplace repo's directory listing directly. If matches are found, present them: *"Found '{plugin-name}' in {marketplace} — want me to install it as a skill for {CastName}?"* If the user accepts, copy the plugin content into `.squad/skills/{plugin-name}/SKILL.md` or merge relevant instructions into the agent's charter. If no marketplaces are configured, skip silently. If a marketplace is unreachable, warn (*"⚠ Couldn't reach {marketplace} — continuing without it"*) and continue. 3. Generate a new charter.md + history.md (seeded with project context from team.md), using the cast name. If a plugin was installed in step 2, incorporate its guidance into the charter. -4. **Update `.squad/casting/registry.json`** with the new agent entry. +4. **Update the casting pair** with the new agent entry using the atomic on-disk protocol described above, not a standalone registry write. 5. Add to team.md roster. 6. Add routing entries to routing.md. 7. Run `squad upgrade` to regenerate Team Capabilities. @@ -667,7 +668,7 @@ If the user wants to remove someone: 1. Move their folder to `.squad/agents/_alumni/{name}/` 2. Remove from team.md roster 3. Update routing.md -4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. +4. **Update the casting pair atomically**: set the registry entry's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved. 5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references. 6. Their knowledge is preserved, just inactive. @@ -737,8 +738,8 @@ After selecting a naming mode: 5. **Ralph is always "Ralph"** — exempt from casting. 6. **Rai is always "Rai"** — exempt from casting. 7. **@copilot is always "@copilot"** — exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name — follow the Copilot Coding Agent Member section instead. -8. Store the mapping in `.squad/casting/registry.json`. -9. Record the assignment snapshot in `.squad/casting/history.json`. +8. Prepare the mapping in `.squad/casting/registry.json`. +9. Record the assignment snapshot in `.squad/casting/history.json` and commit both through the atomic on-disk protocol, including its commit marker. 10. Use the allocated name everywhere: charter.md, history.md, team.md, routing.md, spawn prompts. ### Overflow Handling @@ -755,14 +756,14 @@ Existing agents are NEVER renamed during overflow (only during explicit re-cast) **On-demand reference:** Read `.squad/templates/casting-reference.md` for the full JSON schemas of policy.json, registry.json, and history.json. -The casting system maintains state in `.squad/casting/` with three files: `policy.json` (config), `registry.json` (persistent name registry), and `history.json` (universe usage history + snapshots). +The casting system maintains source-branch configuration in `.squad/casting/`: `policy.json` (policy), `registry.json` (persistent name registry), `history.json` (universe usage history + snapshots), and `registry-history.commit.json` (atomic pair manifest). All remain on disk for every backend. ### Migration — Already-Squadified Repos When `.squad/team.md` exists but `.squad/casting/` does not: 1. **Do NOT rename existing agents.** Mark every existing agent as `legacy_named: true` in the registry. -2. Initialize `.squad/casting/` with default policy.json, a registry.json populated from existing agents, and empty history.json. +2. Initialize `.squad/casting/` with default policy.json and an atomic registry/history pair populated from existing agents. Keep this configuration on the normal source branch. 3. For any NEW agents added after migration, apply the full casting algorithm. 4. Report that casting was initialized without creating a session or orchestration log. diff --git a/test/cli/state-mcp.test.ts b/test/cli/state-mcp.test.ts index fb0425c3a..beb8117b6 100644 --- a/test/cli/state-mcp.test.ts +++ b/test/cli/state-mcp.test.ts @@ -252,13 +252,13 @@ describe('state-mcp bridge', () => { ); it.each(['orphan', 'two-layer'] as const)( - 'writes casting policy but rejects individual casting pair writes through the %s backend', + 'rejects source-branch casting configuration writes through the %s backend', async (stateBackend) => { initSquad(stateBackend); const messages: JsonRpcMessage[] = []; const session = createStateMcpSession(TMP, message => messages.push(message as JsonRpcMessage)); const castingState = [ - ['casting/policy.json', '{"mode":"auto"}\n', false], + ['casting/policy.json', '{"mode":"auto"}\n', true], ['casting/registry.json', '{"agents":{}}\n', true], ['casting/history.json', '{"events":[]}\n', true], ] as const; diff --git a/test/gitignore-state.test.ts b/test/gitignore-state.test.ts index 707a539d1..fd6bc419b 100644 --- a/test/gitignore-state.test.ts +++ b/test/gitignore-state.test.ts @@ -74,6 +74,38 @@ describe('addSquadStateGitignoreBlock', () => { const occurrences = (content.match(/# Squad: state owned by squad-state branch/g) ?? []).length; expect(occurrences).toBe(1); }); + + it.each(['\n', '\r\n'])('upgrades a legacy block once and preserves surrounding rules with %j endings', newline => { + const original = [ + 'node_modules/', + SQUAD_STATE_GITIGNORE_OPEN_MARKER, + '.squad/decisions.md', + '.squad/agents/*/history.md', + SQUAD_STATE_GITIGNORE_CLOSE_MARKER, + 'custom/', + '', + ].join(newline); + storage.writeSync(GITIGNORE_PATH, original); + expect(addSquadStateGitignoreBlock(GITIGNORE_PATH, storage)).toBe(true); + const updated = storage.readSync(GITIGNORE_PATH) ?? ''; + expect(updated).toContain('.squad/memory/'); + expect(updated).toContain('.squad/identity/'); + expect(updated).not.toContain('.squad/casting/'); + expect(updated).toContain('node_modules/'); + expect(updated).toContain('custom/'); + expect(addSquadStateGitignoreBlock(GITIGNORE_PATH, storage)).toBe(false); + expect(storage.readSync(GITIGNORE_PATH)).toBe(updated); + expect(removeSquadStateGitignoreBlock(GITIGNORE_PATH, storage)).toBe(true); + expect(storage.readSync(GITIGNORE_PATH)).toBe(['node_modules/', 'custom/', ''].join(newline)); + }); + + it('refuses an unterminated marker block without replacing user rules', () => { + const original = SQUAD_STATE_GITIGNORE_OPEN_MARKER + '\ncustom/\n'; + storage.writeSync(GITIGNORE_PATH, original); + expect(() => addSquadStateGitignoreBlock(GITIGNORE_PATH, storage)).toThrow('Malformed'); + expect(() => removeSquadStateGitignoreBlock(GITIGNORE_PATH, storage)).toThrow('Malformed'); + expect(storage.readSync(GITIGNORE_PATH)).toBe(original); + }); }); describe('removeSquadStateGitignoreBlock', () => { diff --git a/test/install-hooks-wi1.test.ts b/test/install-hooks-wi1.test.ts index 3de96dac3..9343c9834 100644 --- a/test/install-hooks-wi1.test.ts +++ b/test/install-hooks-wi1.test.ts @@ -14,6 +14,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { commitCastingRegistryPair } from '../packages/squad-sdk/src/casting/durable-registry.js'; import { installGitHooks, ensureHooksForBackend, @@ -94,7 +95,7 @@ describe('WI-1: install-hooks installs commit hooks on two-layer / orphan', () = for (const h of ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout']) { fs.writeFileSync( path.join(hooksDir, h), - '#!/bin/sh\n# --- squad-sync-hook ---\nexit 0\n', + '#!/bin/sh\n# --- squad-sync-hook ---\nif [ -z "$SQUAD_SYNC_ACTIVE" ]; then\n :\nfi\n', { mode: 0o755 }, ); } @@ -116,4 +117,77 @@ describe('WI-1: install-hooks installs commit hooks on two-layer / orphan', () = expect(preCommit).toContain('agents'); expect(preCommit).toContain('history'); }); + + it('refreshes legacy and duplicate guards without deleting user hook content', { timeout: 30_000 }, () => { + writeConfig(dir, 'orphan'); + installGitHooks(dir); + const hook = path.join(dir, '.git', 'hooks', 'pre-commit'); + const current = fs.readFileSync(hook, 'utf8'); + const legacy = current.replace('memory/|identity/', 'casting/|routing/'); + fs.writeFileSync(hook, '#!/bin/sh\n# user prefix\n' + legacy.split('\n').slice(1).join('\n') + + '# user suffix\n' + current.split('\n').slice(1).join('\n')); + ensureHooksForBackend(dir); + const upgraded = fs.readFileSync(hook, 'utf8'); + expect(upgraded).toContain('# user prefix'); + expect(upgraded).toContain('# user suffix'); + expect(upgraded.match(/# --- squad-sync-hook ---/g)).toHaveLength(1); + expect(upgraded).not.toContain('casting/'); + ensureHooksForBackend(dir); + expect(fs.readFileSync(hook, 'utf8')).toBe(upgraded); + installGitHooks(dir, { force: true }); + expect(fs.readFileSync(hook, 'utf8')).toBe(upgraded); + }); + + it('allows atomic casting and routing commits, rejects protected state edits, and allows deletions', { timeout: 60_000 }, () => { + writeConfig(dir, 'two-layer'); + installGitHooks(dir); + const env = { ...process.env, SQUAD_SYNC_ACTIVE: '' }; + const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, env, stdio: 'pipe' }); + const castingDir = path.join(dir, '.squad', 'casting'); + commitCastingRegistryPair(castingDir, undefined, { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + generated_at: '2026-09-20T00:00:00.000Z', agents: {}, + }, undefined, { assignment_cast_snapshots: {}, universe_usage_history: [] }, 1); + fs.writeFileSync(path.join(castingDir, 'policy.json'), '{}\n'); + expect(fs.existsSync(path.join(castingDir, 'registry-history.commit.json'))).toBe(true); + fs.writeFileSync(path.join(dir, '.squad', 'routing.md'), '# Routing\n'); + git('add', '.squad/casting', '.squad/routing.md'); + git('commit', '-qm', 'static casting pair and marker'); + for (const relative of ['decisions.md', 'agents/data/history.md', 'memory/local/record.md', 'identity/data.md']) { + const full = path.join(dir, '.squad', relative); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, 'governed state\n'); + git('add', `.squad/${relative}`); + expect(() => git('commit', '-qm', 'must reject')).toThrow(); + execFileSync('git', ['commit', '-qm', 'seed tracked state'], { + cwd: dir, env: { ...env, SQUAD_SYNC_ACTIVE: '1' }, stdio: 'pipe', + }); + fs.unlinkSync(full); + git('add', `.squad/${relative}`); + git('commit', '-qm', 'remove migrated source'); + } + }); + + it('upgrades the legacy ignore block even when all hooks are already current', { timeout: 30_000 }, () => { + writeConfig(dir, 'orphan'); + installGitHooks(dir); + const ignore = path.join(dir, '.gitignore'); + fs.writeFileSync(ignore, '# project rule\ncustom/\n# Squad: state owned by squad-state branch (two-layer/orphan backend)\n.squad/decisions.md\n.squad/agents/*/history.md\n# /Squad: state owned by squad-state branch\n'); + ensureHooksForBackend(dir); + const updated = fs.readFileSync(ignore, 'utf8'); + expect(updated).toContain('.squad/memory/'); + expect(updated).toContain('.squad/identity/'); + expect(updated).toContain('custom/'); + ensureHooksForBackend(dir); + expect(fs.readFileSync(ignore, 'utf8')).toBe(updated); + }); + + it('refuses to delete an unrecognized marked hook section', () => { + writeConfig(dir, 'orphan'); + const hook = path.join(dir, '.git', 'hooks', 'pre-push'); + const original = '#!/bin/sh\n# --- squad-sync-hook ---\n# custom incomplete section\n'; + fs.writeFileSync(hook, original); + expect(() => installGitHooks(dir, { force: true })).toThrow('Cannot safely replace'); + expect(fs.readFileSync(hook, 'utf8')).toBe(original); + }); }); diff --git a/test/state-backend.test.ts b/test/state-backend.test.ts index 94c9347e9..780143804 100644 --- a/test/state-backend.test.ts +++ b/test/state-backend.test.ts @@ -758,7 +758,7 @@ describe('ToolRegistry state tools with git-native backend', () => { expect(backend.read('sessions/session-1/state.md')).toBeUndefined(); }); - it('allows casting policy but rejects individual registry/history mutations', { timeout: 30_000 }, async () => { + it('keeps all casting configuration out of runtime state mutations', { timeout: 30_000 }, async () => { const backend = new OrphanBranchBackend(TMP); const adapter = new StateBackendStorageAdapter(backend, squadDir()); const registry = new ToolRegistry(squadDir(), undefined, adapter); @@ -766,11 +766,7 @@ describe('ToolRegistry state tools with git-native backend', () => { const append = registry.getTool('squad_state_append')!; const del = registry.getTool('squad_state_delete')!; - await expect(write.handler({ key: 'casting/policy.json', content: '{}\n' })) - .resolves.toMatchObject({ resultType: 'success' }); - expect(backend.read('casting/policy.json')).toBe('{}\n'); - - for (const key of ['casting/registry.json', 'casting/history.json']) { + for (const key of ['casting/policy.json', 'casting/registry.json', 'casting/history.json']) { await expect(write.handler({ key, content: '{}\n' })) .resolves.toMatchObject({ resultType: 'failure' }); await expect(append.handler({ key, content: '{}\n' })) diff --git a/test/tools.test.ts b/test/tools.test.ts index 70659e471..6d87d7058 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -330,7 +330,7 @@ describe('ToolRegistry', () => { // #2107: doctor reads this allowlist to find state stranded in a linked team repo. describe('isMutableStateKey (#2107)', () => { it.each([ - 'decisions.md', 'decisions/inbox/a.md', 'casting/policy.json', 'agents/edie/history.md', + 'decisions.md', 'decisions/inbox/a.md', 'agents/edie/history.md', 'log/a.md', 'orchestration-log/a.md', 'sessions/s.json', '.scratch/n.md', 'identity/now.md', ])('accepts %s', (key) => { expect(isMutableStateKey(key), `${key} must be writable by the state tools`).toBe(true); @@ -338,7 +338,7 @@ describe('ToolRegistry', () => { it.each([ 'team.md', 'routing.md', 'config.json', 'decisions', 'decisions/a.md', 'decisions.md/x', - 'casting/registry.json', 'casting/history.json', 'agents/edie/charter.md', 'agents/edie/history.md/x', + 'casting/policy.json', 'casting/registry.json', 'casting/history.json', 'agents/edie/charter.md', 'agents/edie/history.md/x', 'agents/a/b/history.md', 'log', 'logs/a.md', 'identity', 'skills/x/SKILL.md', ])('rejects %s', (key) => { expect(isMutableStateKey(key), `${key} must not be writable by the state tools`).toBe(false); diff --git a/test/upgrade-state-backend.test.ts b/test/upgrade-state-backend.test.ts index 093129cab..c92009778 100644 --- a/test/upgrade-state-backend.test.ts +++ b/test/upgrade-state-backend.test.ts @@ -11,12 +11,15 @@ * .squad/files/validation/TWOLAYER-BASELINE-INSIDER3-CONSOLIDATED.md (data-5) */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { migrateStateBackend } from '../packages/squad-cli/src/cli/commands/migrate-backend.js'; +import { migrateStateBackend, ensureOrphanBranch, liftInitMutableStateOntoOrphan } from '../packages/squad-cli/src/cli/commands/migrate-backend.js'; +import { LocalMemoryStore, FSStorageProvider } from '@bradygaster/squad-sdk'; +import { OrphanBranchBackend } from '../packages/squad-sdk/src/state-backend.js'; +import { createStateMcpToolRegistry } from '../packages/squad-cli/src/cli/commands/state-mcp.js'; function mkRepo(backend: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-mig-')); @@ -42,7 +45,135 @@ function cleanup(dir: string): void { describe('squad upgrade --state-backend migration', () => { let dir: string; - afterEach(() => dir && cleanup(dir)); + afterEach(() => { + vi.restoreAllMocks(); + if (dir) cleanup(dir); + }); + + it.each(['orphan', 'two-layer'])('preserves actual governed stores and exposes existing memory through MCP after migrating to %s', { timeout: 120_000 }, async target => { + dir = mkRepo('local'); + fs.writeFileSync(path.join(dir, '.squad', 'team.md'), '# Team\n'); + const store = new LocalMemoryStore(new FSStorageProvider(), dir); + for (const requestedClass of ['LOCAL', 'DECISION', 'POLICY'] as const) { + const result = await store.write({ content: `Migration fixture ${requestedClass} guidance`, title: `${requestedClass} fixture`, requestedClass }); + expect(result.stored).toBe(true); + } + const deleted = await store.write({ content: 'Retired fixture guidance', requestedClass: 'LOCAL' }); + expect(await store.delete(deleted.id!)).toBe(true); + fs.writeFileSync(path.join(dir, '.squad', 'memory', 'audit.1.jsonl'), '{"archived":true}\n'); + fs.writeFileSync(path.join(dir, '.squad', 'memory', 'opaque.dat'), Buffer.from([0, 255, 128, 10])); + const files = fs.readdirSync(path.join(dir, '.squad', 'memory'), { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => path.relative(path.join(dir, '.squad'), path.join(entry.parentPath, entry.name)).replaceAll('\\', '/')); + const snapshot = new Map(files.map(relative => [relative, fs.readFileSync(path.join(dir, '.squad', relative))])); + const branch = new OrphanBranchBackend(dir); + branch.write('sessions/existing.md', 'unrelated destination record'); + + await migrateStateBackend(dir, target); + for (const [relative, bytes] of snapshot) { + expect(execFileSync('git', ['show', `squad-state:${relative}`], { cwd: dir })).toEqual(bytes); + expect(fs.existsSync(path.join(dir, '.squad', relative))).toBe(false); + } + expect(branch.read('sessions/existing.md')).toBe('unrelated destination record'); + const registry = createStateMcpToolRegistry(dir); + const search = registry.getTools().find(tool => tool.name === 'memory.search')!; + const args = { query: 'Migration fixture' }; + const result = await search.handler(args, { toolName: 'memory.search', toolCallId: 'migration-search', arguments: args, sessionId: 'test' }) as { textResultForLlm: string }; + expect(result.textResultForLlm).toContain('LOCAL fixture'); + expect(result.textResultForLlm).toContain('DECISION fixture'); + const statePaths = execFileSync('git', ['ls-tree', '-r', '--name-only', 'squad-state'], { cwd: dir, encoding: 'utf8' }); + expect(statePaths).toMatch(/decisions\/inbox\/.+\.md/); + expect(statePaths).toMatch(/memory\/policy-inbox\/.+\.md/); + const head = execFileSync('git', ['rev-parse', 'squad-state'], { cwd: dir, encoding: 'utf8' }); + await migrateStateBackend(dir, target); + expect(execFileSync('git', ['rev-parse', 'squad-state'], { cwd: dir, encoding: 'utf8' })).toBe(head); + }); + + it('refuses conflicting destination indices without switching authority or changing either store', { timeout: 30_000 }, async () => { + dir = mkRepo('local'); + fs.mkdirSync(path.join(dir, '.squad', 'memory')); + fs.writeFileSync(path.join(dir, '.squad', 'memory', 'index.json'), '[]\n'); + const branch = new OrphanBranchBackend(dir); + branch.write('memory/index.json', '[{"id":"destination-record"}]\n'); + const head = execFileSync('git', ['rev-parse', 'squad-state'], { cwd: dir, encoding: 'utf8' }); + await expect(migrateStateBackend(dir, 'orphan')).rejects.toThrow('Conflicting state at memory/index.json'); + expect(branch.read('memory/index.json')).toBe('[{"id":"destination-record"}]\n'); + expect(fs.readFileSync(path.join(dir, '.squad', 'memory', 'index.json'), 'utf8')).toBe('[]\n'); + expect(JSON.parse(fs.readFileSync(path.join(dir, '.squad', 'config.json'), 'utf8')).stateBackend).toBe('local'); + expect(execFileSync('git', ['rev-parse', 'squad-state'], { cwd: dir, encoding: 'utf8' })).toBe(head); + expect(fs.readdirSync(path.join(dir, '.git')).filter(name => name.startsWith('squad-migrate-'))).toEqual([]); + }); + + it('retries an identical import after config publication fails without losing source or creating another commit', { timeout: 30_000 }, async () => { + dir = mkRepo('worktree'); + const source = path.join(dir, '.squad', 'decisions.md'); + fs.writeFileSync(source, '# Existing decision\n'); + const rename = fs.renameSync.bind(fs); + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to) === path.join(dir, '.squad', 'config.json')) throw new Error('injected config failure'); + rename(from, to); + }); + await expect(migrateStateBackend(dir, 'two-layer')).rejects.toThrow('injected config failure'); + expect(fs.readFileSync(source, 'utf8')).toBe('# Existing decision\n'); + expect(JSON.parse(fs.readFileSync(path.join(dir, '.squad', 'config.json'), 'utf8')).stateBackend).toBe('worktree'); + const head = execFileSync('git', ['rev-parse', 'squad-state'], { cwd: dir, encoding: 'utf8' }); + vi.restoreAllMocks(); + await migrateStateBackend(dir, 'two-layer'); + expect(fs.existsSync(source)).toBe(false); + expect(execFileSync('git', ['rev-parse', 'squad-state'], { cwd: dir, encoding: 'utf8' })).toBe(head); + expect(fs.readdirSync(path.join(dir, '.squad')).filter(name => name.startsWith('.config-migrate-'))).toEqual([]); + }); + + it('retains config and source when orphan import cannot publish its ref', { timeout: 30_000 }, async () => { + dir = mkRepo('local'); + expect(ensureOrphanBranch(dir)).toBe(true); + fs.writeFileSync(path.join(dir, '.squad', 'decisions.md'), 'keep source'); + const lock = path.join(dir, '.git', 'refs', 'heads', 'squad-state.lock'); + fs.writeFileSync(lock, 'blocked'); + await expect(migrateStateBackend(dir, 'orphan')).rejects.toThrow('Could not migrate state'); + expect(fs.readFileSync(path.join(dir, '.squad', 'decisions.md'), 'utf8')).toBe('keep source'); + expect(JSON.parse(fs.readFileSync(path.join(dir, '.squad', 'config.json'), 'utf8')).stateBackend).toBe('local'); + }); + + it('detects local edits made during import before switching backend authority', { timeout: 30_000 }, async () => { + dir = mkRepo('local'); + const source = path.join(dir, '.squad', 'decisions.md'); + fs.writeFileSync(source, 'original snapshot\n'); + const remove = fs.rmSync.bind(fs); + vi.spyOn(fs, 'rmSync').mockImplementation((file, options) => { + remove(file, options); + if (path.basename(String(file)).startsWith('squad-migrate-')) { + fs.writeFileSync(source, 'concurrent edit\n'); + } + }); + await expect(migrateStateBackend(dir, 'orphan')).rejects.toThrow('Source changed during migration'); + expect(fs.readFileSync(source, 'utf8')).toBe('concurrent edit\n'); + expect(JSON.parse(fs.readFileSync(path.join(dir, '.squad', 'config.json'), 'utf8')).stateBackend).toBe('local'); + expect(new OrphanBranchBackend(dir).read('decisions.md')).toBe('original snapshot\n'); + }); + + it.each(['null', '[]', '{bad json'])('refuses malformed config %s before starting migration', async raw => { + dir = mkRepo('local'); + const config = path.join(dir, '.squad', 'config.json'); + fs.writeFileSync(config, raw); + await expect(migrateStateBackend(dir, 'orphan')).rejects.toThrow('Cannot read squad config'); + expect(fs.readFileSync(config, 'utf8')).toBe(raw); + expect(() => execFileSync('git', ['rev-parse', '--verify', 'squad-state'], { cwd: dir, stdio: 'pipe' })).toThrow(); + }); + + it('init lifts the governed scaffold while leaving all static casting files on disk', { timeout: 30_000 }, async () => { + dir = mkRepo('orphan'); + const store = new LocalMemoryStore(new FSStorageProvider(), dir); + await store.write({ content: 'Init fixture guidance', requestedClass: 'LOCAL' }); + const castingDir = path.join(dir, '.squad', 'casting'); + fs.mkdirSync(castingDir); + const castingFiles = ['registry.json', 'history.json', 'policy.json', 'registry-history.commit.json']; + for (const file of castingFiles) fs.writeFileSync(path.join(castingDir, file), '{}\n'); + expect(ensureOrphanBranch(dir)).toBe(true); + expect(liftInitMutableStateOntoOrphan(dir)).toContain('memory/index.json'); + for (const file of castingFiles) expect(fs.readFileSync(path.join(castingDir, file), 'utf8')).toBe('{}\n'); + expect(execFileSync('git', ['ls-tree', '-r', '--name-only', 'squad-state'], { cwd: dir, encoding: 'utf8' })).not.toContain('casting/'); + }); it('UPGRADE-FLAG-IGNORED: writes stateBackend to config.json with no duplicate keys', { timeout: 30_000 }, async () => { dir = mkRepo('worktree'); From 6564e55430e4c42d95ba4e337dde5fb4f83175a8 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 9 Nov 2026 18:54:06 +0200 Subject: [PATCH 2/2] fix(doctor): preserve legacy stray casting/policy.json detection in linked team roots (#2107, #2229) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/commands/doctor.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index c37bcd9fa..6891ad06a 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -185,6 +185,12 @@ function checkTeamRootResolves(squadDir: string, teamRoot: string): DoctorCheck const STRAY_STATE_FILE = /\.(md|json)$/; const STRAY_STATE_MAX_DEPTH = 4; const STRAY_STATE_LIST_LIMIT = 10; +const LEGACY_STRANDED_STATE_PATHS: ReadonlyArray<{ readonly root: string; readonly kind: 'file' | 'dir' }> = [ + ...MUTABLE_STATE_PATHS, + // Pre-#2229 state tools could also write casting/policy.json before #2107 fixed linked team roots. + { root: 'casting/policy.json', kind: 'file' }, +]; +const LEGACY_STRANDED_STATE_KEYS = new Set(['casting/policy.json']); /** Push `/`-separated keys of files under `base/rel`. Skips dirs it cannot list. */ function collectFileKeys(base: string, rel: string, depth: number, out: string[]): void { @@ -225,7 +231,7 @@ function checkStrandedLinkedTeamState(squadDir: string): DoctorCheck | undefined if (paths.teamSquadDir === paths.teamDir) return undefined; const candidates: string[] = []; - for (const { root, kind } of MUTABLE_STATE_PATHS) { + for (const { root, kind } of LEGACY_STRANDED_STATE_PATHS) { const full = path.join(paths.teamDir, root); if (kind === 'file') { if (fileExists(full) && !isDirectory(full)) candidates.push(root); @@ -235,7 +241,9 @@ function checkStrandedLinkedTeamState(squadDir: string): DoctorCheck | undefined } // Match only what the state tools could write, so an unrelated code dir // named agents/ or log/ does not trigger advice to move it. - const stray = candidates.filter(key => isMutableStateKey(key) && STRAY_STATE_FILE.test(key)); + const stray = candidates.filter( + key => (isMutableStateKey(key) || LEGACY_STRANDED_STATE_KEYS.has(key)) && STRAY_STATE_FILE.test(key), + ); if (stray.length === 0) return undefined; const toMerge = stray.filter(key => fileExists(path.join(paths.teamSquadDir, key)));