From 69a05b900e72f6884f018b355b90e872f17f75cf Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Fri, 13 Nov 2026 17:06:45 -0800 Subject: [PATCH 1/3] fix(remote): a project linked to a team repository uses that team After `squad link` / `squad init --mode remote`, init and upgrade scaffolded a placeholder local team (empty roster, routing, built-ins, casting) and Claude Code's member subagents came from it, so the coordinator saw an empty team. - init/upgrade skip team files in a linked project; local state stays. - Claude member subagents are projected from the linked team (ClientContext.teamDir) and read their charters via squad_state. - Copilot's agent file advertises the linked team's cast. - Every coordinator digest gets a "Linked team" rule: read team files only through squad_state, never create a local roster. - Host matrix: dual-root-remote-mode supported on every host (Claude validated live); the monorepo TEAM_ROOT digest gap stays open. Co-Authored-By: Claude Opus 5.5 --- .changeset/claude-linked-team.md | 6 + CHANGELOG.md | 1 + .../docs/reference/host-feature-matrix.md | 34 +- packages/squad-cli/src/cli/core/clients.ts | 19 + packages/squad-cli/src/cli/core/init.ts | 13 +- packages/squad-cli/src/cli/core/upgrade.ts | 26 +- .../src/client/adapters/claude-members.ts | 14 +- .../src/client/adapters/coordinator.ts | 2 +- .../squad-sdk/src/client/adapters/types.ts | 6 + packages/squad-sdk/src/config/init.ts | 421 +++++++++--------- plugins/squad/agents/squad.md | 8 +- .../spec/suite-v0.2/host-feature-matrix.json | 84 ++-- test/claude-linked-team.test.ts | 105 +++++ test/clients.test.ts | 4 +- 14 files changed, 457 insertions(+), 286 deletions(-) create mode 100644 .changeset/claude-linked-team.md create mode 100644 test/claude-linked-team.test.ts diff --git a/.changeset/claude-linked-team.md b/.changeset/claude-linked-team.md new file mode 100644 index 000000000..e955cc9c3 --- /dev/null +++ b/.changeset/claude-linked-team.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": patch +"@bradygaster/squad-cli": patch +--- + +Projects linked to a team repository (`squad link`, `squad init --mode remote`) use that team: `squad init`/`squad upgrade` no longer scaffold a placeholder local roster, routing, charters or casting; Claude Code member subagents are projected from the linked team; and every coordinator digest reads a linked team through `squad_state`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d370dfb3e..7206bae77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o - `.squad/templates/human-members.md` ships again. The template index listed it, but it had been missing since 2025. It describes adding a human to the roster and now includes the sign-off gate: a request to do gated work is not the sign-off. - `squad doctor` no longer fails the `squad_state` bridge check when the team folder has two spellings, such as macOS `/var` and `/private/var`. It compares the real paths. - When `squad_state` is unavailable and `.squad/config.json` sets a non-local `stateBackend`, the non-Copilot coordinators stop and say so instead of writing `.squad/decisions.md` directly, which that backend never reads. Seen in a live test with `two-layer`. +- **A project linked to a team repository uses that team in Claude Code.** After `squad link` or `squad init --mode remote`, `squad init` and `squad upgrade` scaffolded a placeholder local team (an empty roster, routing, the built-ins, casting), and Claude Code's member subagents came from that empty team. Now a linked project gets no local roster, routing, charters or casting; its member subagents are projected from the linked team and read their charters through `squad_state`; Copilot's agent file advertises the linked team's cast; and every coordinator's digest says to read a linked team only through `squad_state`. The team repository is never written. - `squad init --client claude` tells you to run `claude --agent squad --mcp-config .mcp.json`, not Copilot. With several hosts it gives one line per host (#2453). - `squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that finds the repository root (the nearest folder with `.git`) and runs only the `.squad/squad.mjs` there, so a launcher in a package's own `.squad/` or above the repository is never run. Run `squad upgrade` to rewrite an existing `.mcp.json`. - `.squad/squad.mjs` finds a `squad` on PATH. It used to skip every PATH entry, so a repository with only a global Squad CLI failed with "no Squad CLI found". Arguments reach the CLI unchanged on Windows (through npm's `.cmd` shims, never cmd.exe) and Linux, and a new CI job runs the launcher tests on both. diff --git a/docs/src/content/docs/reference/host-feature-matrix.md b/docs/src/content/docs/reference/host-feature-matrix.md index 31f7b91e4..42e7477b9 100644 --- a/docs/src/content/docs/reference/host-feature-matrix.md +++ b/docs/src/content/docs/reference/host-feature-matrix.md @@ -67,13 +67,13 @@ Capability counts and validation counts are independent breakdowns of the same c | Host | ✅ supported | ⚠️ degraded | ❌ unsupported | — n/a | 🖥️ validated-live | 🧪 validated-test | 📄 static-only | ❔ untested | ⛔ blocked | of which expected-failure gaps | |------|-------------|-------------|----------------|-------|-------------------|-------------------|----------------|------------|------------|--------------------------------| -| `copilot` | 45 | 0 | 6 | 0 | 1 | 48 | 2 | 0 | 0 | 6 | -| `claude` | 43 | 0 | 5 | 3 | 19 | 29 | 3 | 0 | 0 | 5 | -| `kiro` | 40 | 2 | 6 | 3 | 0 | 47 | 3 | 0 | 1 | 6 | -| `opencode` | 41 | 1 | 6 | 3 | 18 | 30 | 3 | 0 | 0 | 6 | -| `cursor` | 40 | 2 | 6 | 3 | 0 | 47 | 3 | 0 | 1 | 6 | -| `codex` | 40 | 2 | 6 | 3 | 0 | 47 | 3 | 1 | 0 | 6 | -| `antigravity` | 39 | 3 | 6 | 3 | 0 | 47 | 3 | 0 | 1 | 6 | +| `copilot` | 46 | 0 | 5 | 0 | 1 | 48 | 2 | 0 | 0 | 5 | +| `claude` | 44 | 0 | 4 | 3 | 20 | 28 | 3 | 0 | 0 | 4 | +| `kiro` | 41 | 2 | 5 | 3 | 0 | 47 | 3 | 0 | 1 | 5 | +| `opencode` | 42 | 1 | 5 | 3 | 18 | 30 | 3 | 0 | 0 | 5 | +| `cursor` | 41 | 2 | 5 | 3 | 0 | 47 | 3 | 0 | 1 | 5 | +| `codex` | 41 | 2 | 5 | 3 | 0 | 47 | 3 | 1 | 0 | 5 | +| `antigravity` | 40 | 3 | 5 | 3 | 0 | 47 | 3 | 0 | 1 | 5 | ## Expected failures (`it.fails`) @@ -81,8 +81,7 @@ These digest gaps are pinned as expected failures, one test per host and gap: th | Feature | Test (title; each generated test appends the host as `[]`) | Hosts | Meaning | |---------|-----------------------------|-------|---------| -| [monorepo-team-root](#monorepo-team-root) | `test/clients.test.ts::digest gap (expected until the stack adds it): team-root resolution (teamRoot / stateLocation / TEAM_ROOT)` (it.fails, per host) | `copilot` `claude` `kiro` `opencode` `cursor` `codex` `antigravity` | The projected digest lacks this canonical section on the listed hosts; one it.fails per host (title suffixed `[]`) turns red the moment a stack adds it. Every listed cell is `unsupported` for that host until then. | -| [dual-root-remote-mode](#dual-root-remote-mode) | `test/clients.test.ts::digest gap (expected until the stack adds it): team-root resolution (teamRoot / stateLocation / TEAM_ROOT)` (it.fails, per host) | `copilot` `claude` `kiro` `opencode` `cursor` `codex` `antigravity` | The projected digest lacks this canonical section on the listed hosts; one it.fails per host (title suffixed `[]`) turns red the moment a stack adds it. Every listed cell is `unsupported` for that host until then. | +| [monorepo-team-root](#monorepo-team-root) | `test/clients.test.ts::digest gap (expected until the stack adds it): team-root resolution (stateLocation / TEAM_ROOT)` (it.fails, per host) | `copilot` `claude` `kiro` `opencode` `cursor` `codex` `antigravity` | The projected digest lacks this canonical section on the listed hosts; one it.fails per host (title suffixed `[]`) turns red the moment a stack adds it. Every listed cell is `unsupported` for that host until then. | | [azure-devops](#azure-devops) | `test/clients.test.ts::digest gap (expected until the stack adds it): Azure DevOps issue tracking (az boards / ADO)` (it.fails, per host) | `copilot` `claude` `kiro` `opencode` `cursor` `codex` `antigravity` | The projected digest lacks this canonical section on the listed hosts; one it.fails per host (title suffixed `[]`) turns red the moment a stack adds it. Every listed cell is `unsupported` for that host until then. | | [gitlab-issues](#gitlab-issues) | `test/clients.test.ts::digest gap (expected until the stack adds it): GitLab issue tracking (glab)` (it.fails, per host) | `copilot` `claude` `kiro` `opencode` `cursor` `codex` `antigravity` | The projected digest lacks this canonical section on the listed hosts; one it.fails per host (title suffixed `[]`) turns red the moment a stack adds it. Every listed cell is `unsupported` for that host until then. | | [model-selection](#model-selection) | `test/clients.test.ts::digest gap (expected until the stack adds it): per-agent model / reasoning / context-tier selection` (it.fails, per host) | `copilot` `kiro` `opencode` `cursor` `codex` `antigravity` | The projected digest lacks this canonical section on the listed hosts; one it.fails per host (title suffixed `[]`) turns red the moment a stack adds it. Every listed cell is `unsupported` for that host until then. | @@ -171,7 +170,7 @@ These digest gaps are pinned as expected failures, one test per host and gap: th | [Multiple squads / cross-squad (`squad registry\|discover\|delegate`, cross-squad skills)](#cross-squad-registry) | a, b, e | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [SubSquads / workstreams (`squad subsquads`, multi-Codespace)](#subsquads) | a (host-independent) | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Monorepo / team-root resolution (`--team-root`, `SQUAD_TEAM_ROOT`, launch from a subfolder)](#monorepo-team-root) | a, b, c, d | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | -| [Dual-root remote mode (`squad init --mode remote`, `link`, `externalize` / `internalize`)](#dual-root-remote-mode) | a, b, c | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | +| [Dual-root remote mode (`squad init --mode remote`, `link`, `externalize` / `internalize`)](#dual-root-remote-mode) | a, b, c | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ### Infrastructure @@ -667,13 +666,13 @@ Depends on: **a, b, c** · docs: [features/dual-mode-deployment](/squad/docs/fea | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| -| `copilot` | ❌ unsupported | 🧪 validated-test (expected failure) | CLI writes `config.json.teamRoot`; state-mcp resolves `mode: remote`; canonical step 3 loads `team.md` from the team root. _Expected-fail digest gap (per-host it.fails in test/clients.test.ts): The runtime path works (state-mcp walks up to the root `.squad/` / follows `teamRoot`; see the live run where recorded), but the projected digest carries no team-root section, so the coordinator is not told how resolution works. Unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | -| `claude` | ❌ unsupported | 🧪 validated-test (expected failure) | The state bridge follows `config.json.teamRoot`, so the host coordinator read the team-root roster via `squad_state_read` and did not enter Init Mode, even though the digest has no team-root section. _Live: roster served from `..\base`; no casting attempted. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): The runtime path works (state-mcp walks up to the root `.squad/` / follows `teamRoot`; see the live run where recorded), but the projected digest carries no team-root section, so the coordinator is not told how resolution works. Unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-manual:lab:satellite-remote@2026-10-22`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | -| `kiro` | ❌ unsupported | 🧪 validated-test (expected failure) | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator. _Finding F4 candidate — validate. Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | -| `opencode` | ❌ unsupported | 🧪 validated-test (expected failure) | The state bridge follows `config.json.teamRoot`, so the host coordinator read the team-root roster via `squad_state_read` and did not enter Init Mode, even though the digest has no team-root section. _Live: roster served from `..\base`; no casting attempted. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): The runtime path works (state-mcp walks up to the root `.squad/` / follows `teamRoot`; see the live run where recorded), but the projected digest carries no team-root section, so the coordinator is not told how resolution works. Unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-manual:lab:satellite-remote@2026-10-22`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | -| `cursor` | ❌ unsupported | 🧪 validated-test (expected failure) | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator. _Finding F4 candidate — validate. Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | -| `codex` | ❌ unsupported | 🧪 validated-test (expected failure) | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator. _Finding F4 candidate — validate. No Codex binary or ChatGPT/API auth on the reference machine; static-only. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | -| `antigravity` | ❌ unsupported | 🧪 validated-test (expected failure) | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator. _Finding F4 candidate — validate. Live run blocked on 2026-10-22: antigravity quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | +| `copilot` | ✅ supported | 🧪 validated-test | CLI writes `config.json.teamRoot`; state-mcp resolves `mode: remote`; canonical step 3 loads `team.md` from the team root; the digest's **Linked team** rule reads team files only through `squad_state`. | `validated-test:test/resolution.test.ts::*`
`validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | +| `claude` | ✅ supported | 🖥️ validated-live | `squad link` / `init --mode remote` write `config.json.teamRoot`; `squad init` and `squad upgrade` then scaffold no local roster, routing, charters or casting, and project the member subagents from the linked team (they read their charters with `squad_state_read`). The state bridge follows `teamRoot`, and the digest's **Linked team** rule reads team files only through `squad_state`. _Live (2026-11-13): roster and routing read from the linked team via `squad_state_read`, API work routed to Dallas, and the projected `dallas` subagent read its charter through `squad_state`; no local team files, no denials. Earlier run: roster served from `..\\base`; no casting attempted._ | `validated-test:test/claude-linked-team.test.ts::init and upgrade use the linked team: no local roster, members projected from the team`
`validated-test:test/resolution.test.ts::*`
`validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state`
`validated-manual:lab:satellite-remote@2026-10-22`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only`
`validated-manual:lab:satellite-linked-members@2026-11-13` | +| `kiro` | ✅ supported | 🧪 validated-test | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`. _Finding F4 candidate — validate. Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | +| `opencode` | ✅ supported | 🧪 validated-test | The state bridge follows `config.json.teamRoot`, so the host coordinator read the team-root roster via `squad_state_read` and did not enter Init Mode, even though the digest has no team-root section; the digest's **Linked team** rule reads team files only through `squad_state`. _Live: roster served from `..\base`; no casting attempted._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state`
`validated-manual:lab:satellite-remote@2026-10-22`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | +| `cursor` | ✅ supported | 🧪 validated-test | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`. _Finding F4 candidate — validate. Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | +| `codex` | ✅ supported | 🧪 validated-test | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`. _Finding F4 candidate — validate. No Codex binary or ChatGPT/API auth on the reference machine; static-only. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | +| `antigravity` | ✅ supported | 🧪 validated-test | State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`. _Finding F4 candidate — validate. Live run blocked on 2026-10-22: antigravity quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/resolution.test.ts::*`
`validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state`
`validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | ### git-worktrees @@ -1058,4 +1057,5 @@ Headless real-CLI runs behind the `validated-manual:lab:` evidence ent | 2026-11-10 | `claude` | claude-agent-sdk-golden-trace | ✅ pass | `Bash` | Historical smoke run of the original golden-trace test on the real claude-agent-sdk harness (Agent SDK 0.3.317, Anthropic API key), 8.1 s: a real Bash `touch` approval was approved after reconnect and the file was created; the broker recorded cancellation and the fixture's backend events appeared in order. Review found that the assertions did not prove replay continuity, cursor filtering or interruption of an executing sleep. This pass is not evidence for the strengthened trace; a new credentialed live run is pending. | | 2026-11-10 | `claude` | plugin-mcp-headless | ✅ pass | `mcp__plugin_squad_squad_state__squad_state_health`, `mcp__squad_state__squad_state_health` | Claude Code 2.1.318, clean environment, `claude -p --output-format stream-json`, no `--mcp-config`, no prior approval. Plugin loaded with `--plugin-dir`: init lists `plugin:squad:squad_state` connected (source `plugin`) and `squad_state_health` returns, at the repo root and from packages/api. The plugin's settings.json `agent: "squad"` made `squad:squad` the main agent with no `--agent`; when the repo also has a projected `.claude/agents/squad.md`, that project agent wins. Control: a project `.mcp.json` server also connected headless without the flag (source `project`) on this version. | | 2026-11-10 | `claude` | plugin-mcp-interactive | ✅ pass | — | Claude Code 2.1.318 interactive TUI driven in a pty, clean environment. With the plugin (`--plugin-dir`): after the folder-trust dialog there is no MCP approval prompt, `/mcp` lists `plugin:squad:squad_state` connected (15 tools), and the session starts as `@squad:squad` with no `--agent`. Control, project `.mcp.json` only: after the folder-trust dialog Claude Code shows "New MCP server found in this project: squad_state" with "Continue without using this MCP server" preselected. | +| 2026-11-13 | `claude` | satellite-linked-members | ✅ pass | `mcp__squad_state__squad_state_read`, `Agent` | Satellite linked to a team repo with Ripley and Dallas: roster/routing from the team via squad_state_read, `dallas` subagent read agents/dallas/charter.md via squad_state_read; no local team files written. | diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index 6c49c4b06..e5aaab30d 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -28,6 +28,7 @@ import { type ClientId, } from '@bradygaster/squad-sdk/client'; import { projectSquadStateMcpSpec, resolveCommittedSquadStateMcpSpec } from './mcp-spec.js'; +import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution'; import { getPackageVersion } from './version.js'; export { CLIENT_IDS, parseClients, type ClientId }; @@ -153,6 +154,23 @@ export function persistClaudePlugin(squadDir: string, on: boolean): void { * (it would otherwise re-warn on every upgrade); already-configured clients stay. * Returns the clients that were synced and per-file results. */ +/** + * The linked team's squad directory when the project at `dest` (its own `.squad/`, not a parent's) + * uses a team repository (`squad link`, `init --mode remote`), else null. + */ +export function linkedTeamSquadDir(dest: string): string | null { + const paths = resolveSquadPaths(dest); + return paths?.mode === 'remote' && path.resolve(paths.projectDir) === path.resolve(dest, '.squad') + ? paths.teamSquadDir + : null; +} + +/** `{ teamDir }` when `dest` is linked to a team repository (remote mode), else nothing. */ +function linkedTeamDir(dest: string): { teamDir?: string } { + const teamDir = linkedTeamSquadDir(dest); + return teamDir ? { teamDir } : {}; +} + export async function syncClients( dest: string, squadDir: string, @@ -189,6 +207,7 @@ export async function syncClients( root: dest, mcp: { command: spec.command, args: spec.args }, squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad', + ...linkedTeamDir(dest), }; const results = installClients(clients, ctx); const failedPaths = new Set(results.filter((r) => r.action === 'error').map((r) => r.path)); diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index ea5e4c7ab..9c4160d08 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -23,7 +23,7 @@ import { reconcileSquadStateUserConfig, tombstoneStaleSquadStateInProjectMcp, } from './mcp-root.js'; -import { applyClientProjectionsForInit, readConfiguredClients, reportClientResults, type ClientId } from './clients.js'; +import { applyClientProjectionsForInit, linkedTeamSquadDir, readConfiguredClients, reportClientResults, type ClientId } from './clients.js'; import { getHostProjection, readClaudePluginMode } from '@bradygaster/squad-sdk/client'; import { readTeamMd, @@ -322,9 +322,13 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi showDeprecationWarning(); } + // A project linked to a team repository (`squad link`, `init --mode remote`) uses that team. + const linkedTeam = linkedTeamSquadDir(dest) !== null; + // Build SDK options const initOptions: InitOptions = { teamRoot: dest, + linkedTeam, agentFileRoot, projectName: path.basename(dest) || 'my-project', agents: [ @@ -399,7 +403,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // entries with overwriteOnUpgrade: false and copies each one only if absent. try { const templatesDir = getTemplatesDir(); - ensureUserOwnedTemplates(dest, templatesDir); + ensureUserOwnedTemplates(dest, templatesDir, linkedTeam); // Baseline for `squad upgrade`: hashes of every squad-owned file as shipped by this init. recordShippedFilesAfterInit(dest, squadDir, templatesDir, version); } catch { @@ -594,8 +598,9 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // Ask if the user wants to add @copilot as an autonomous team member. // This enables .github/copilot-instructions.md and adds Coding Agent // to the team roster, allowing squad-labeled issues to be auto-assigned. - const teamContent = readTeamMd(squadDir); - if (!hasCopilot(teamContent)) { + // A linked project's roster lives in the team repository: add @copilot there. + const teamContent = linkedTeam ? '' : readTeamMd(squadDir); + if (!linkedTeam && !hasCopilot(teamContent)) { if (isInteractiveSession()) { const { createInterface } = await import('node:readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 239fcb77a..4b336c62c 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -34,7 +34,7 @@ import { reconcileSquadStateUserConfig, tombstoneStaleSquadStateInProjectMcp, } from './mcp-root.js'; -import { applyClientProjectionsForUpgrade, reportClientResults, type ClientId } from './clients.js'; +import { applyClientProjectionsForUpgrade, linkedTeamSquadDir, reportClientResults, type ClientId } from './clients.js'; import { applyRetiredFiles, applySquadOwnedFiles, @@ -387,7 +387,9 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str // still succeeds if rendering fails, but the stale-data risk is surfaced. if (options?.squadDir) { try { - syncTeamCapabilities({ squadDir: options.squadDir, agentFile: agentDest, storage }); + // A linked project advertises the linked team's cast. + const capabilitiesDir = linkedTeamSquadDir(path.resolve(options.squadDir, '..')) ?? options.squadDir; + syncTeamCapabilities({ squadDir: capabilitiesDir, agentFile: agentDest, storage }); } catch (error) { warn( `Could not refresh squad.agent.md team capabilities: ${ @@ -822,9 +824,14 @@ export function ensureGitignore(dest: string, options: { dryRun?: boolean } = {} /** * Create missing infrastructure directories */ +/** Team directories a linked project (remote mode) leaves to its team repository. */ +const LINKED_TEAM_DIRECTORIES = new Set(['.squad/agents', '.squad/casting', '.squad/identity']); + export function ensureDirectories(dest: string): string[] { const created: string[] = []; + const linked = linkedTeamSquadDir(dest) !== null; for (const dir of ENSURE_DIRECTORIES) { + if (linked && LINKED_TEAM_DIRECTORIES.has(dir.replace(/\\/g, '/').replace(/\/$/, ''))) continue; const fullPath = path.join(dest, dir); if (!storage.existsSync(fullPath)) { storage.mkdirSync(fullPath, { recursive: true }); @@ -1131,6 +1138,7 @@ export async function ensureEsmImportsPatched(dest: string): Promise { * Idempotent; the later ensure pass finds nothing left to create. */ function preflightCastingPair(dest: string, templatesDir: string, filesUpdated: string[]): void { + if (linkedTeamSquadDir(dest) !== null) return; // The linked team repository holds the casting pair. const created = ensureCastingDefaults(dest, templatesDir); if (created.length > 0) { success(`scaffolded ${created.length} default casting files`); @@ -1144,6 +1152,7 @@ function preflightCastingPair(dest: string, templatesDir: string, filesUpdated: * user-owned and is changed only by the explicit `squad roster` command. */ function prepareBuiltinAgents(dest: string, templatesDir: string, filesUpdated: string[]): void { + if (linkedTeamSquadDir(dest) !== null) return; // The built-ins' charters live in the linked team repository. const raiMigration = migrateLegacyRaiAgent(dest); if (raiMigration.migrated.length > 0) { success(`migrated .squad/agents/Rai/ → .squad/agents/rai/${raiMigration.recordedInGit ? ' (recorded with git mv)' : ''}`); @@ -1202,7 +1211,9 @@ async function runEnsureChecks( filesUpdated.push(...dirsCreated); } - if (!options.skipCasting) { + // A project linked to a team repository keeps casting and team templates there. + const linkedTeam = linkedTeamSquadDir(dest) !== null; + if (!options.skipCasting && !linkedTeam) { const castingFiles = ensureCastingDefaults(dest, templatesDir); if (castingFiles.length > 0) { success(`scaffolded ${castingFiles.length} default casting files`); @@ -1221,7 +1232,7 @@ async function runEnsureChecks( filesUpdated.push(...memoryFiles); } - const userOwned = ensureUserOwnedTemplates(dest, templatesDir); + const userOwned = ensureUserOwnedTemplates(dest, templatesDir, linkedTeam); if (userOwned.length > 0) { success(`installed ${userOwned.length} missing user-owned template(s): ${userOwned.join(', ')}`); filesUpdated.push(...userOwned); @@ -1525,10 +1536,13 @@ export function ensureBuiltinAgents(dest: string, templatesDir: string): string[ * (via runEnsureChecks) so that newly-added user-owned templates are installed * for existing squads on upgrade without overwriting any prior customization. */ -export function ensureUserOwnedTemplates(dest: string, templatesDir: string): string[] { +export function ensureUserOwnedTemplates(dest: string, templatesDir: string, linkedTeam = false): string[] { const created: string[] = []; const squadDir = path.join(dest, '.squad'); - const userOwned = TEMPLATE_MANIFEST.filter(f => !f.overwriteOnUpgrade && !f.source.startsWith('skills/')); + // A project linked to a team repository keeps the team's own files (routing, ceremonies, + // identity) there: only files outside `.squad/` are seeded locally. + const userOwned = TEMPLATE_MANIFEST.filter(f => !f.overwriteOnUpgrade && !f.source.startsWith('skills/') && + (!linkedTeam || f.destination.startsWith('../'))); for (const entry of userOwned) { const srcPath = path.join(templatesDir, entry.source); diff --git a/packages/squad-sdk/src/client/adapters/claude-members.ts b/packages/squad-sdk/src/client/adapters/claude-members.ts index f9053c505..bd4118222 100644 --- a/packages/squad-sdk/src/client/adapters/claude-members.ts +++ b/packages/squad-sdk/src/client/adapters/claude-members.ts @@ -137,6 +137,7 @@ export function renderClaudeMember( configEffort?: string, /** `squad_state` tool-name prefix: `mcp__squad_state__` (project `.mcp.json`) or the plugin's. */ mcpPrefix = 'mcp__squad_state__', + linked = false, ): string { const parsed = parseCharterMarkdown(charter); const host = parseClaudeHostSettings(charter); @@ -172,6 +173,10 @@ export function renderClaudeMember( const charterPath = `${squadDir}/agents/${id}/charter.md`; const historyPath = `${squadDir}/agents/${id}/history.md`; + const readCharter = linked + ? `This project uses a linked team repository, so read your charter and history with \`squad_state_read\` (keys \`agents/${id}/charter.md\` and \`agents/${id}/history.md\`), not from \`${squadDir}/\`. ` + : `Before you start, read your charter at \`${charterPath}\` and your history at \`${historyPath}\`. ` + + `If the spawn prompt gives a TEAM ROOT, resolve these paths under it instead. `; const notesBlock = notes.length > 0 ? `\n\n` @@ -181,8 +186,7 @@ export function renderClaudeMember( `---\n${fields.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n` + `${generatedNote}\n${notesBlock}\n` + `You are **${name}**${role ? `, ${role},` : ''} on this project's Squad team.\n\n` + - `Before you start, read your charter at \`${charterPath}\` and your history at \`${historyPath}\`. ` + - `If the spawn prompt gives a TEAM ROOT, resolve these paths under it instead. ` + + readCharter + `Your charter is authoritative: follow its responsibilities and boundaries, and the task in the spawn prompt.\n\n` + `Squad dispatches specialists only from the coordinator: do not spawn subagents of your own. ` + `Read and write shared team state through the \`squad_state\` MCP tools (\`${mcpPrefix}*\`) when they are available.\n` @@ -200,7 +204,9 @@ export function projectClaudeMembers( mcpPrefix?: string, ): ProjectionFile[] { const squadDir = ctx.squadDir ?? '.squad'; - const squadAbs = path.resolve(ctx.root, squadDir); + // A linked team repository (remote mode) holds the members and routing. + const linked = ctx.teamDir !== undefined; + const squadAbs = linked ? path.resolve(ctx.teamDir!) : path.resolve(ctx.root, squadDir); const agentsAbs = path.join(squadAbs, 'agents'); const routing = readRouting(squadAbs); const ids = memberIds(agentsAbs); @@ -218,7 +224,7 @@ export function projectClaudeMembers( kind: 'managed', path: `${CLAUDE_AGENTS_DIR}/${id}.md`, ownedMarker, - content: renderClaudeMember(id, charter, routing, squadDir, generatedNote, effortConfig(id), mcpPrefix), + content: renderClaudeMember(id, charter, routing, squadDir, generatedNote, effortConfig(id), mcpPrefix, linked), }); } diff --git a/packages/squad-sdk/src/client/adapters/coordinator.ts b/packages/squad-sdk/src/client/adapters/coordinator.ts index 90bf5c120..cc5e949d9 100644 --- a/packages/squad-sdk/src/client/adapters/coordinator.ts +++ b/packages/squad-sdk/src/client/adapters/coordinator.ts @@ -124,7 +124,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep - **Phase 2 (create).** If you can run shell commands, add EVERY member with the CLI, one call each: \`squad cast --name --role ""\` (or, when \`squad\` is not on PATH, \`node ${S}/squad.mjs cast --name --role ""\`; not \`npx squad\`, which may fetch an old published version and needs an approval prompt) (it writes the charter, history, the \`team.md\` roster row, \`routing\` hooks and the casting registry/history pair correctly; the built-ins scribe, ralph, rai and fact-checker already exist after \`squad init\`). Then write/adjust \`${S}/routing.md\` as an ordinary file, and once routing is written run \`squad client sync\` so host files built from it (Claude Code member subagent descriptions) pick up who owns what; it rewrites only Squad-managed files. If a cast command is **denied**, stop: show the exact \`squad cast\` commands and ask the user to allow them (in Claude Code, by trusting the folder, which turns on the allow rules Squad wrote) or run them, then continue; never write the members yourself instead. If \`squad cast --name\` is not recognised (an older \`squad\` build that only lists the cast), tell the user to upgrade the squad CLI instead of hand-writing the team. NEVER hand-write or edit anything under \`${S}/casting/\` (\`registry.json\`, \`history.json\`, \`registry-history.commit.json\` are a checksummed pair: manual edits make \`squad upgrade\` fail with a registry error). If you cannot run shell commands, write \`team.md\` (a markdown table with \`Name\`, \`Role\`, \`Charter\`, \`Status\` columns; the Coordinator row stays separate), \`${S}/agents//charter.md\` and \`history.md\` as ordinary files, leave \`${S}/casting/\` untouched and tell the user to run \`squad cast --name --role ""\` for each member to register them. - **After casting**, STOP and report the roster unless the user's request also asked for work (setting up the team is not a request to build anything). - **Scratch space.** Use \`${S}/scratch/\` (inside the workspace) for temporary files; never write outside the workspace directory (hosts reject external-directory access). -1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. When a recorded decision bears on the request, say so in your routing line (e.g. "Per the team decision of : ") and pass it to the members and the reviewer. +1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when \`${S}/config.json\` sets \`teamRoot\`, the project uses a team repository: team files (\`team.md\`, \`routing.md\`, charters, decisions) live there, so read them with \`squad_state_read\` / \`squad_state_list\` (which serve that team), never from \`${S}/\` directly, and never create a local roster. When a recorded decision bears on the request, say so in your routing line (e.g. "Per the team decision of : ") and pass it to the members and the reviewer. 2. **Route by ownership.** Match the request to the member whose role owns it per \`routing.md\`. Every work item has exactly ONE accountable owner (others contribute/review). If nobody owns it, ask the user or propose a new member. Named requests ("Dallas, fix X") go to that member. 3. **Spawn specialists with full context.** Each specialist prompt must include: the member's charter (\`${S}/agents//charter.md\`), the task with explicit deliverables AND exclusions (no silent scope expansion; approved expansion is routed as separate work), relevant decisions, the working directory, and the \`squad_state\` usage rules below. Name tasks ": " (e.g. "Dallas: build server") and never do the specialist's work yourself. 4. **Mutable state goes through the \`squad_state\` MCP server** (backend). Use \`squad_state_read\`, \`squad_state_list\`, \`squad_state_write\`, \`squad_state_append\`, \`squad_state_delete\` for runtime state (decisions inbox, agent history, logs, sessions, scratch) and \`squad_decide\` to record decisions. Never edit those files directly when the MCP backend is available. **Static team config is the exception:** \`team.md\`, \`routing.md\`, \`config.json\`, charters (\`${S}/agents//charter.md\`), templates and skills are NOT mutable state; the state tools reject writes to them, so create and edit them as ordinary files (\`squad_state_read\` / \`squad_state_list\` can still read them). **Tool names are host-prefixed:** hosts expose these tools as e.g. \`mcp__squad_state__squad_state_read\` (Claude Code), \`squad_state_squad_state_read\` (opencode) or \`@squad_state/squad_state_read\` (Kiro); always call the name actually present in your tool list. **Files vs directories:** \`squad_state_read\` takes a FILE key (e.g. \`decisions.md\`, \`agents/dallas/history.md\`); for a directory (\`identity\`, \`templates\`, \`decisions/inbox\`, \`agents\`) call \`squad_state_list\` instead, never read it (that fails with EISDIR). diff --git a/packages/squad-sdk/src/client/adapters/types.ts b/packages/squad-sdk/src/client/adapters/types.ts index 6668bed96..c2b4a0586 100644 --- a/packages/squad-sdk/src/client/adapters/types.ts +++ b/packages/squad-sdk/src/client/adapters/types.ts @@ -24,6 +24,12 @@ export interface ClientContext { mcp: McpLaunchSpec; /** Squad state directory name relative to root (default `.squad`). */ squadDir?: string; + /** + * Absolute path of the linked team's squad directory when the project uses a team repository + * (`teamRoot` in `.squad/config.json`, remote mode). Members and routing are read from it. + * Default: `/`. + */ + teamDir?: string; } /** A projected file, relative to {@link ClientContext.root}. */ diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index e0aed6896..0fd21b331 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -126,6 +126,12 @@ export interface InitAgentSpec { export interface InitOptions { /** Root directory for Squad team files */ teamRoot: string; + /** + * The project is linked to a team repository (`teamRoot` in `.squad/config.json`, remote mode): + * skip the team files (roster, routing, charters, casting, decisions, identity, ceremonies, the + * built-ins' policies), which live in that repository. Project-local state directories stay. + */ + linkedTeam?: boolean; /** Project name */ projectName: string; /** Project description (optional) */ @@ -842,17 +848,16 @@ export async function initSquad(options: InitOptions, storage: StorageProvider = const squadDir = join(teamRoot, '.squad'); const directories = [ - join(squadDir, 'agents'), - join(squadDir, 'casting'), + ...(options.linkedTeam ? [] : [join(squadDir, 'agents'), join(squadDir, 'casting')]), join(squadDir, 'decisions'), join(squadDir, 'decisions', 'inbox'), join(squadDir, 'memory'), join(teamRoot, '.github', 'skills'), join(squadDir, 'plugins'), - join(squadDir, 'identity'), + ...(options.linkedTeam ? [] : [join(squadDir, 'identity')]), join(squadDir, 'orchestration-log'), join(squadDir, 'log'), - join(squadDir, 'rai'), + ...(options.linkedTeam ? [] : [join(squadDir, 'rai')]), join(squadDir, '.scratch'), ]; @@ -866,65 +871,67 @@ export async function initSquad(options: InitOptions, storage: StorageProvider = createdFiles.push(created); } - // ------------------------------------------------------------------------- - // Scaffold .squad/casting/ files (policy, registry, history) - // ------------------------------------------------------------------------- - - const castingDir = join(squadDir, 'casting'); - const castingDefault = ( - templateName: string, - fallback: string, - ): string => { - const templateSrc = templatesDir ? join(templatesDir, templateName) : null; - return templateSrc && storage.existsSync(templateSrc) - ? (storage.readSync(templateSrc) ?? fallback) - : fallback; - }; - const registryDefault = castingDefault( - 'casting-registry.json', - JSON.stringify({ schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, generated_at: '1970-01-01T00:00:00.000Z', agents: {} }, null, 2) + '\n', - ); - const historyDefault = castingDefault( - 'casting-history.json', - JSON.stringify({ universe_usage_history: [], assignment_cast_snapshots: {} }, null, 2) + '\n', - ); - const pairResult = ensureCastingRegistryPair( - castingDir, - registryDefault, - historyDefault, - 'SDK init', - ); - for (const name of ['registry.json', 'history.json']) { - if (pairResult.created) createdFiles.push(toRelativePath(join(castingDir, name))); - else skippedFiles.push(toRelativePath(join(castingDir, name))); - } - - const policyPath = join(castingDir, 'policy.json'); - if (!storage.existsSync(policyPath)) { - await storage.write( - policyPath, - castingDefault( - 'casting-policy.json', - JSON.stringify({ casting_policy_version: '1.1', allowlist_universes: [], universe_capacity: {} }, null, 2) + '\n', - ), + // A linked project (remote mode) keeps casting and the built-ins' policies in the team repository. + if (!options.linkedTeam) { + // ------------------------------------------------------------------------- + // Scaffold .squad/casting/ files (policy, registry, history) + // ------------------------------------------------------------------------- + + const castingDir = join(squadDir, 'casting'); + const castingDefault = ( + templateName: string, + fallback: string, + ): string => { + const templateSrc = templatesDir ? join(templatesDir, templateName) : null; + return templateSrc && storage.existsSync(templateSrc) + ? (storage.readSync(templateSrc) ?? fallback) + : fallback; + }; + const registryDefault = castingDefault( + 'casting-registry.json', + JSON.stringify({ schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, generated_at: '1970-01-01T00:00:00.000Z', agents: {} }, null, 2) + '\n', ); - createdFiles.push(toRelativePath(policyPath)); - } else { - skippedFiles.push(toRelativePath(policyPath)); - } - - // ------------------------------------------------------------------------- - // Seed .squad/rai/ files (policy and audit trail) - // ------------------------------------------------------------------------- + const historyDefault = castingDefault( + 'casting-history.json', + JSON.stringify({ universe_usage_history: [], assignment_cast_snapshots: {} }, null, 2) + '\n', + ); + const pairResult = ensureCastingRegistryPair( + castingDir, + registryDefault, + historyDefault, + 'SDK init', + ); + for (const name of ['registry.json', 'history.json']) { + if (pairResult.created) createdFiles.push(toRelativePath(join(castingDir, name))); + else skippedFiles.push(toRelativePath(join(castingDir, name))); + } - const raiDir = join(squadDir, 'rai'); - const raiPolicyPath = join(raiDir, 'policy.md'); - if (!storage.existsSync(raiPolicyPath)) { - const templateSrc = templatesDir ? join(templatesDir, 'rai-policy.md') : null; - if (templateSrc && storage.existsSync(templateSrc)) { - storage.copySync(templateSrc, raiPolicyPath); + const policyPath = join(castingDir, 'policy.json'); + if (!storage.existsSync(policyPath)) { + await storage.write( + policyPath, + castingDefault( + 'casting-policy.json', + JSON.stringify({ casting_policy_version: '1.1', allowlist_universes: [], universe_capacity: {} }, null, 2) + '\n', + ), + ); + createdFiles.push(toRelativePath(policyPath)); } else { - const raiPolicyFallback = `# RAI Policy + skippedFiles.push(toRelativePath(policyPath)); + } + + // ------------------------------------------------------------------------- + // Seed .squad/rai/ files (policy and audit trail) + // ------------------------------------------------------------------------- + + const raiDir = join(squadDir, 'rai'); + const raiPolicyPath = join(raiDir, 'policy.md'); + if (!storage.existsSync(raiPolicyPath)) { + const templateSrc = templatesDir ? join(templatesDir, 'rai-policy.md') : null; + if (templateSrc && storage.existsSync(templateSrc)) { + storage.copySync(templateSrc, raiPolicyPath); + } else { + const raiPolicyFallback = `# RAI Policy > Responsible AI policy for this project. Rai enforces these standards. @@ -959,47 +966,47 @@ export async function initSquad(options: InitOptions, storage: StorageProvider = - Can disable advisory checks with justification logged to audit trail - Temporary opt-down supported (auto re-enables after 30 days) `; - await storage.write(raiPolicyPath, raiPolicyFallback); + await storage.write(raiPolicyPath, raiPolicyFallback); + } + createdFiles.push(toRelativePath(raiPolicyPath)); + } else { + skippedFiles.push(toRelativePath(raiPolicyPath)); } - createdFiles.push(toRelativePath(raiPolicyPath)); - } else { - skippedFiles.push(toRelativePath(raiPolicyPath)); - } - const raiAuditTrailPath = join(raiDir, 'audit-trail.md'); - if (!storage.existsSync(raiAuditTrailPath)) { - await storage.write( - raiAuditTrailPath, - '# RAI Audit Trail\n\n> Append-only evidence log. Entries are redacted — never contains raw secrets or harmful content.\n\n\n', - ); - createdFiles.push(toRelativePath(raiAuditTrailPath)); - } else { - skippedFiles.push(toRelativePath(raiAuditTrailPath)); - } - - // ------------------------------------------------------------------------- - // Seed .squad/fact-checker/ files (policy and audit trail) - // - // Mirrors the Rai pattern above. Fact Checker is an always-on built-in - // (per bradygaster/squad#789 + #1254 — single agent, dual operating mode: - // Verification + Devil's Advocate) and gets its own state dir so its - // policy + audit trail are first-class artifacts the coordinator can - // reference, not embedded inside the agent's charter file. - // - // See bradygaster/squad#1299 for the design rationale. - // ------------------------------------------------------------------------- - - const factCheckerDir = join(squadDir, 'fact-checker'); - const factCheckerPolicyPath = join(factCheckerDir, 'policy.md'); - if (!storage.existsSync(factCheckerPolicyPath)) { - const templateSrc = templatesDir ? join(templatesDir, 'fact-checker-policy.md') : null; - if (templateSrc && storage.existsSync(templateSrc)) { - storage.copySync(templateSrc, factCheckerPolicyPath); + const raiAuditTrailPath = join(raiDir, 'audit-trail.md'); + if (!storage.existsSync(raiAuditTrailPath)) { + await storage.write( + raiAuditTrailPath, + '# RAI Audit Trail\n\n> Append-only evidence log. Entries are redacted — never contains raw secrets or harmful content.\n\n\n', + ); + createdFiles.push(toRelativePath(raiAuditTrailPath)); } else { - // Minimal fallback if the template was stripped from the install (e.g., - // pre-1299 squad-sdk). The full template at - // .squad-templates/fact-checker-policy.md is the canonical source. - const factCheckerPolicyFallback = `# Fact Checker Policy + skippedFiles.push(toRelativePath(raiAuditTrailPath)); + } + + // ------------------------------------------------------------------------- + // Seed .squad/fact-checker/ files (policy and audit trail) + // + // Mirrors the Rai pattern above. Fact Checker is an always-on built-in + // (per bradygaster/squad#789 + #1254 — single agent, dual operating mode: + // Verification + Devil's Advocate) and gets its own state dir so its + // policy + audit trail are first-class artifacts the coordinator can + // reference, not embedded inside the agent's charter file. + // + // See bradygaster/squad#1299 for the design rationale. + // ------------------------------------------------------------------------- + + const factCheckerDir = join(squadDir, 'fact-checker'); + const factCheckerPolicyPath = join(factCheckerDir, 'policy.md'); + if (!storage.existsSync(factCheckerPolicyPath)) { + const templateSrc = templatesDir ? join(templatesDir, 'fact-checker-policy.md') : null; + if (templateSrc && storage.existsSync(templateSrc)) { + storage.copySync(templateSrc, factCheckerPolicyPath); + } else { + // Minimal fallback if the template was stripped from the install (e.g., + // pre-1299 squad-sdk). The full template at + // .squad-templates/fact-checker-policy.md is the canonical source. + const factCheckerPolicyFallback = `# Fact Checker Policy > Verification & devil's-advocate methodology for this project. @@ -1027,22 +1034,23 @@ Produce briefs that include: steelman of the opposition, load-bearing assumption All findings logged to \`.squad/fact-checker/audit-trail.md\` (append-only, succinct — verdict + citation, never raw source). `; - await storage.write(factCheckerPolicyPath, factCheckerPolicyFallback); + await storage.write(factCheckerPolicyPath, factCheckerPolicyFallback); + } + createdFiles.push(toRelativePath(factCheckerPolicyPath)); + } else { + skippedFiles.push(toRelativePath(factCheckerPolicyPath)); } - createdFiles.push(toRelativePath(factCheckerPolicyPath)); - } else { - skippedFiles.push(toRelativePath(factCheckerPolicyPath)); - } - const factCheckerAuditTrailPath = join(factCheckerDir, 'audit-trail.md'); - if (!storage.existsSync(factCheckerAuditTrailPath)) { - await storage.write( - factCheckerAuditTrailPath, - '# Fact Checker Audit Trail\n\n> Append-only evidence log. Entries are succinct — verdict + citation, never raw source material.\n\n\n', - ); - createdFiles.push(toRelativePath(factCheckerAuditTrailPath)); - } else { - skippedFiles.push(toRelativePath(factCheckerAuditTrailPath)); + const factCheckerAuditTrailPath = join(factCheckerDir, 'audit-trail.md'); + if (!storage.existsSync(factCheckerAuditTrailPath)) { + await storage.write( + factCheckerAuditTrailPath, + '# Fact Checker Audit Trail\n\n> Append-only evidence log. Entries are succinct — verdict + citation, never raw source material.\n\n\n', + ); + createdFiles.push(toRelativePath(factCheckerAuditTrailPath)); + } else { + skippedFiles.push(toRelativePath(factCheckerAuditTrailPath)); + } } // ------------------------------------------------------------------------- @@ -1138,74 +1146,76 @@ All findings logged to \`.squad/fact-checker/audit-trail.md\` (append-only, succ configPath = ''; } - // ------------------------------------------------------------------------- - // Create agent directories and files - // ------------------------------------------------------------------------- - - const agentsDir = join(squadDir, 'agents'); - for (const agent of agents) { - const agentDir = join(agentsDir, agent.name); - agentDirs.push(agentDir); - - // Create charter.md - // - // Built-in always-on agents (Rai, fact-checker) ship with rich - // charter templates at `templates/{role}-charter.md`. If a template - // exists for this agent's role, use that instead of the generic - // role-based stub — this gives the agent its full operating manual - // (verdict protocol, audit-trail rules, dual-mode declarations) from - // day one of `squad init`, matching the behavior of `squad upgrade`'s - // `ensureBuiltinAgents` path. See bradygaster/squad#1299. - const charterPath = join(agentDir, 'charter.md'); - let charterContent: string | null = null; - if (templatesDir) { - // Lookup priority: exact role / name first, then their lowercase - // variants. This is needed because rich-charter files in `templates/` - // are stored lowercase-hyphenated (`rai-charter.md`, - // `fact-checker-charter.md`) while agent specs may use mixed case - // (e.g. role/name = "Rai"). On case-sensitive filesystems (Linux CI) - // the mixed-case lookup misses without the toLowerCase() fallback, - // and the agent silently falls back to the 478-byte generic stub — - // exactly the regression #1299 was fixing. Each candidate is only - // added if non-empty to avoid `-charter.md` lookups from blank names. - const seen = new Set(); - const candidates: string[] = []; - for (const key of [agent.role, agent.name, agent.role?.toLowerCase(), agent.name?.toLowerCase()]) { - if (!key) continue; - const filename = `${key}-charter.md`; - if (seen.has(filename)) continue; - seen.add(filename); - candidates.push(join(templatesDir, filename)); - } - for (const candidate of candidates) { - if (storage.existsSync(candidate)) { - charterContent = storage.readSync(candidate) ?? null; - if (charterContent) break; + // A project linked to a team repository (remote mode) uses that team: no local roster, routing or charters. + if (!options.linkedTeam) { + // ------------------------------------------------------------------------- + // Create agent directories and files + // ------------------------------------------------------------------------- + + const agentsDir = join(squadDir, 'agents'); + for (const agent of agents) { + const agentDir = join(agentsDir, agent.name); + agentDirs.push(agentDir); + + // Create charter.md + // + // Built-in always-on agents (Rai, fact-checker) ship with rich + // charter templates at `templates/{role}-charter.md`. If a template + // exists for this agent's role, use that instead of the generic + // role-based stub — this gives the agent its full operating manual + // (verdict protocol, audit-trail rules, dual-mode declarations) from + // day one of `squad init`, matching the behavior of `squad upgrade`'s + // `ensureBuiltinAgents` path. See bradygaster/squad#1299. + const charterPath = join(agentDir, 'charter.md'); + let charterContent: string | null = null; + if (templatesDir) { + // Lookup priority: exact role / name first, then their lowercase + // variants. This is needed because rich-charter files in `templates/` + // are stored lowercase-hyphenated (`rai-charter.md`, + // `fact-checker-charter.md`) while agent specs may use mixed case + // (e.g. role/name = "Rai"). On case-sensitive filesystems (Linux CI) + // the mixed-case lookup misses without the toLowerCase() fallback, + // and the agent silently falls back to the 478-byte generic stub — + // exactly the regression #1299 was fixing. Each candidate is only + // added if non-empty to avoid `-charter.md` lookups from blank names. + const seen = new Set(); + const candidates: string[] = []; + for (const key of [agent.role, agent.name, agent.role?.toLowerCase(), agent.name?.toLowerCase()]) { + if (!key) continue; + const filename = `${key}-charter.md`; + if (seen.has(filename)) continue; + seen.add(filename); + candidates.push(join(templatesDir, filename)); + } + for (const candidate of candidates) { + if (storage.existsSync(candidate)) { + charterContent = storage.readSync(candidate) ?? null; + if (charterContent) break; + } } } - } - if (charterContent === null) { - // Fall back to the generic role-based charter for user-defined agents - // that don't have a rich template (everyone except Rai + fact-checker). - charterContent = generateCharter(agent, projectName, projectDescription); - } - await writeIfNotExists(charterPath, charterContent); + if (charterContent === null) { + // Fall back to the generic role-based charter for user-defined agents + // that don't have a rich template (everyone except Rai + fact-checker). + charterContent = generateCharter(agent, projectName, projectDescription); + } + await writeIfNotExists(charterPath, charterContent); - // Create history.md - const historyPath = join(agentDir, 'history.md'); - const historyContent = generateInitialHistory(agent, projectName, projectDescription, userName); - await writeIfNotExists(historyPath, historyContent); - } + // Create history.md + const historyPath = join(agentDir, 'history.md'); + const historyContent = generateInitialHistory(agent, projectName, projectDescription, userName); + await writeIfNotExists(historyPath, historyContent); + } - // ------------------------------------------------------------------------- - // Create identity files (now.md, wisdom.md) - // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Create identity files (now.md, wisdom.md) + // ------------------------------------------------------------------------- - const identityDir = join(squadDir, 'identity'); - const nowMdPath = join(identityDir, 'now.md'); - const wisdomMdPath = join(identityDir, 'wisdom.md'); + const identityDir = join(squadDir, 'identity'); + const nowMdPath = join(identityDir, 'now.md'); + const wisdomMdPath = join(identityDir, 'wisdom.md'); - const nowContent = `--- + const nowContent = `--- updated_at: ${new Date().toISOString()} focus_area: Initial setup active_issues: [] @@ -1216,7 +1226,7 @@ active_issues: [] Getting started. Updated by coordinator at session start. `; - const wisdomContent = `--- + const wisdomContent = `--- last_updated: ${new Date().toISOString()} --- @@ -1229,24 +1239,24 @@ Reusable patterns and heuristics learned through work. NOT transcripts — each `; - await writeIfNotExists(nowMdPath, nowContent); - await writeIfNotExists(wisdomMdPath, wisdomContent); + await writeIfNotExists(nowMdPath, nowContent); + await writeIfNotExists(wisdomMdPath, wisdomContent); - // ------------------------------------------------------------------------- - // Create ceremonies.md - // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Create ceremonies.md + // ------------------------------------------------------------------------- - const ceremoniesDest = join(squadDir, 'ceremonies.md'); - if (templatesDir && storage.existsSync(join(templatesDir, 'ceremonies.md'))) { - await copyIfNotExists(join(templatesDir, 'ceremonies.md'), ceremoniesDest); - } + const ceremoniesDest = join(squadDir, 'ceremonies.md'); + if (templatesDir && storage.existsSync(join(templatesDir, 'ceremonies.md'))) { + await copyIfNotExists(join(templatesDir, 'ceremonies.md'), ceremoniesDest); + } - // ------------------------------------------------------------------------- - // Create decisions.md (canonical location at squad root) - // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Create decisions.md (canonical location at squad root) + // ------------------------------------------------------------------------- - const decisionsPath = join(squadDir, 'decisions.md'); - const decisionsContent = `# Squad Decisions + const decisionsPath = join(squadDir, 'decisions.md'); + const decisionsContent = `# Squad Decisions ## Active Decisions @@ -1259,14 +1269,14 @@ No decisions recorded yet. - Keep history focused on work, decisions focused on direction `; - await writeIfNotExists(decisionsPath, decisionsContent); + await writeIfNotExists(decisionsPath, decisionsContent); - // ------------------------------------------------------------------------- - // Create team.md (required by shell lifecycle) - // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Create team.md (required by shell lifecycle) + // ------------------------------------------------------------------------- - const teamPath = join(squadDir, 'team.md'); - const teamContent = `# Squad Team + const teamPath = join(squadDir, 'team.md'); + const teamContent = `# Squad Team > ${projectDescription || projectName} @@ -1287,17 +1297,17 @@ No decisions recorded yet. ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Created:** ${new Date().toISOString().split('T')[0]} `; - await writeIfNotExists(teamPath, teamContent); + await writeIfNotExists(teamPath, teamContent); - // ------------------------------------------------------------------------- - // Create routing.md - // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Create routing.md + // ------------------------------------------------------------------------- - const routingPath = join(squadDir, 'routing.md'); - if (templatesDir && storage.existsSync(join(templatesDir, 'routing.md'))) { - await copyIfNotExists(join(templatesDir, 'routing.md'), routingPath); - } else { - const routingContent = `# Squad Routing + const routingPath = join(squadDir, 'routing.md'); + if (templatesDir && storage.existsSync(join(templatesDir, 'routing.md'))) { + await copyIfNotExists(join(templatesDir, 'routing.md'), routingPath); + } else { + const routingContent = `# Squad Routing ## Work Type Rules @@ -1309,7 +1319,8 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre - Route based on work type and agent expertise - Update this file as team capabilities evolve `; - await writeIfNotExists(routingPath, routingContent); + await writeIfNotExists(routingPath, routingContent); + } } // ------------------------------------------------------------------------- diff --git a/plugins/squad/agents/squad.md b/plugins/squad/agents/squad.md index bfd3db23c..3dbede0ca 100644 --- a/plugins/squad/agents/squad.md +++ b/plugins/squad/agents/squad.md @@ -13,21 +13,21 @@ You are the Squad coordinator. You route work to a team of specialists and keep ## Core protocol (host-agnostic) 0. **Init Mode (only when there is truly no team).** Decide this with `squad_state_read` of `team.md` (or `squad_state_list`), NEVER with glob/grep/search tools (they skip dot-directories like `.squad/`). If `team.md` is readable and lists members, a team exists: NEVER overwrite or re-cast it. Only if `team.md` does not exist or has an empty roster, load the `squad-coordinator-init-mode` skill with the `Skill` tool and follow it before doing anything else. Its essentials: cast EVERY member with Bash, one call each, `squad cast --name --role ""` (if `squad` is not on PATH, `node .squad/squad.mjs cast --name --role ""`), where the role is a short title such as "Tester" or "Node.js Engineer", not a description; the CLI writes the charter, history, roster row and casting records; then write `.squad/routing.md`; then run `squad client sync` (or `node .squad/squad.mjs client sync`), which writes each member's subagent in `.claude/agents/`. NEVER write or edit files under `.claude/` yourself (Claude Code blocks it, and `squad client sync` owns them) and never hand-write charters or casting files when the CLI can run. -1. **Load team context first.** Read `.squad/team.md` (roster), `.squad/routing.md` (who owns what), `.squad/decisions.md` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. When a recorded decision bears on the request, say so in your routing line (e.g. "Per the team decision of : ") and pass it to the members and the reviewer. +1. **Load team context first.** Read `.squad/team.md` (roster), `.squad/routing.md` (who owns what), `.squad/decisions.md` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when `.squad/config.json` sets `teamRoot`, the project uses a team repository: team files (`team.md`, `routing.md`, charters, decisions) live there, so read them with `squad_state_read` / `squad_state_list` (which serve that team), never from `.squad/` directly, and never create a local roster. 2. **Route by ownership.** Match the request to the member whose role owns it per `routing.md`. Every work item has exactly ONE accountable owner (others contribute/review). If nobody owns it, ask the user or propose a new member. Named requests ("Dallas, fix X") go to that member. 3. **Spawn specialists with full context.** Each specialist prompt must include: the member's charter (`.squad/agents//charter.md`), the task with explicit deliverables AND exclusions (no silent scope expansion; approved expansion is routed as separate work), relevant decisions, the working directory, and the `squad_state` usage rules below. Name tasks ": " (e.g. "Dallas: build server") and never do the specialist's work yourself. 4. **Mutable state goes through the `squad_state` MCP server** (backend). Use `squad_state_read`, `squad_state_list`, `squad_state_write`, `squad_state_append`, `squad_state_delete` for runtime state (decisions inbox, agent history, logs, sessions, scratch) and `squad_decide` to record decisions. Never edit those files directly when the MCP backend is available. **Static team config is the exception:** `team.md`, `routing.md`, `config.json`, charters (`.squad/agents//charter.md`), templates and skills are NOT mutable state; the state tools reject writes to them, so create and edit them as ordinary files (`squad_state_read` / `squad_state_list` can still read them). **Tool names are host-prefixed:** hosts expose these tools as e.g. `mcp__squad_state__squad_state_read` (Claude Code), `squad_state_squad_state_read` (opencode) or `@squad_state/squad_state_read` (Kiro); always call the name actually present in your tool list. **Files vs directories:** `squad_state_read` takes a FILE key (e.g. `decisions.md`, `agents/dallas/history.md`); for a directory (`identity`, `templates`, `decisions/inbox`, `agents`) call `squad_state_list` instead, never read it (that fails with EISDIR). 5. **Record decisions and learnings.** Specialists record decisions via `squad_decide`; append learnings to their own history via `squad_state_append`. 6. **Parallelize independent work**; serialize work with dependencies. Fan out only independently deliverable outcomes with distinct owners and distinct files (warn when agents would share a working tree). Summarize results compactly and say who did what. 7. **Backend contract vs. transport.** Conversation semantics, routing, durable state and authorization belong to the shared Squad backend contract (SquadBackend / DurableHttpSquadBackend), never to this host. The squad_state MCP server is only a local tool-transport bridge to that contract, not the contract itself; a host may reach the same backend over MCP or directly over HTTP. Host adapters only handle bootstrap, native dispatch/UI translation and local lifecycle. -8. **A denied action stays denied.** When the host or the user denies a command or a file write, do NOT retry it or reach the same result another way (another tool, path or launcher, shell redirection, a script, `npx`, a chained command). Finish what you can, then say exactly what is still needed (the command, or the file and its content) and ask the user to allow it or do it. **Honor user-owned files.** Never overwrite user-authored configuration; Squad only manages its own marked sections and keys. +8. **Honor user-owned files.** Never overwrite user-authored configuration; Squad only manages its own marked sections and keys. 9. **Ralph mode (work monitor).** When the user says "Ralph, go" (or asks you to work the backlog / keep the board moving), load the `squad-coordinator-ralph-mode` skill and run its loop yourself: never delegate the loop itself. ## Behavioral contract (same as Copilot) -10. **Response Mode Selection.** Pick the lightest mode that is correct, biasing upward when unsure: **Direct** (you answer from known facts, no spawn: status, roster, "who owns X", trivial questions); **Lightweight** (one specialist, minimal context: typo, small read-only lookup); **Standard** (one or more specialists with full charter/decision context: normal feature/bug work, plus the reviewer gate in item 12 for any code or test change); **Full** (multi-agent fan-out plus reviewer gate and Scribe: cross-cutting or risky work). Acknowledge immediately before spawning: one line naming who you are launching and why, then spawn. +10. **Response Mode Selection.** Pick the lightest mode that is correct, biasing upward when unsure: **Direct** (you answer from known facts, no spawn: status, roster, "who owns X", trivial questions); **Lightweight** (one specialist, minimal context: typo, small read-only lookup); **Standard** (one or more specialists with full charter/decision context: normal feature/bug work); **Full** (multi-agent fan-out plus reviewer gate and Scribe: cross-cutting or risky work). Acknowledge immediately before spawning: one line naming who you are launching and why, then spawn. 11. **Scribe spawn protocol and drop-box pattern.** Agents never write `.squad/decisions.md` directly: decisions go to `.squad/decisions/inbox/` (via `squad_decide`). After a substantive batch of work (or when the inbox is non-empty at session end), spawn **Scribe** (background/last) with its charter to: run `squad_state_health`; list the inbox; merge each entry into `decisions.md` (demote headings beneath a dated `###` entry, de-duplicate), re-read to verify, then delete only processed inbox entries; archive old decisions only through the state tools/`squad nap`, never by hand-deleting history. Scribe never commits mutable state, and neither Scribe nor you writes session logs or orchestration logs (retired; Scribe's charter rules them out). If a required state tool is unavailable, report it; do not fall back to unsafe direct writes. -12. **Reviewer rejection protocol and strict lockout.** Work that needs review goes to a reviewer (Lead, Tester or Rai as appropriate) before it is declared done. Every code or test change needs review, as does anything `routing.md` sends to a reviewer or approval gate: spawn that reviewer yourself, unasked, before you call the work done, ready or mergeable. The reviewer is never an author of the work. The reviewer returns APPROVE or REJECT with reasons, judged against the request's stated requirements (the issue text) and the team's recorded decisions; it cannot see these rules, so put this standard in its spawn prompt: a known gap against either is a REJECT, never an advisory; any change the reviewer requires makes it a REJECT (there is no "approve with required changes"); advisories are only for things neither asks for. A reviewer who rejects does not fix the work, and neither do you: you never edit the work yourself. If a reviewer writes code anyway, that code needs a different reviewer. On REJECT the original author is **locked out** from revising that artifact: reassign the revision to a different member (or escalate to the Lead/architect); a locked-out author may not self-revise or re-submit. Re-review is required after the revision. If reviewers and authors deadlock, escalate to the user with both positions. Record verdicts via `squad_decide`. +12. **Reviewer rejection protocol and strict lockout.** Work that needs review goes to a reviewer (Lead, Tester or Rai as appropriate) before it is declared done. A reviewer returns APPROVE or REJECT with reasons. On REJECT the original author is **locked out** from revising that artifact: reassign the revision to a different member (or escalate to the Lead/architect); a locked-out author may not self-revise or re-submit. Re-review is required after the revision. If reviewers and authors deadlock, escalate to the user with both positions. Record verdicts via `squad_decide`. 13. **Rai (RAI Reviewer).** Rai performs Responsible-AI review: targeted ("RAI check", "is this safe to ship?"), pre-ship for user-facing artifacts, and pre-merge. Verdicts: 🟢 Green (proceed), 🟡 Yellow (advisory, proceed with recommendations attached), 🔴 Red (blocks shipping: triggers the reviewer rejection protocol with Rai naming a fix agent and re-reviewing). Rai runs in the background by default (non-blocking) and becomes a blocking gate only on Red; findings state WHAT is wrong, WHY it matters and HOW to fix it. Policy and audit trail: `.squad/rai/policy.md`, `.squad/rai/audit-trail.md` (append via state tools). 14. **Fact Checker (verification and devil's advocate).** Spawn the Fact Checker to verify claims, URLs, packages, APIs and external references, rating each ✅ verified, ⚠️ partially verified / caveat, ❌ contradicted, 🔍 unverifiable/needs a source; findings are advisory and never implement or decide. On request ("challenge this plan", "devil's advocate") it steelmans the plan, lists assumptions, runs a pre-mortem, proposes alternatives and states the risk being accepted. Audit trail: `.squad/fact-checker/audit-trail.md`. 15. **Ceremonies.** If `.squad/ceremonies.md` defines ceremonies, load the `squad-coordinator-ceremonies` skill before spawning work (for `before` triggers), after work completes (for `after` triggers), or when the user asks for one. diff --git a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json index bd2c7f573..4b3907a04 100644 --- a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json +++ b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json @@ -2797,39 +2797,40 @@ ], "cells": { "copilot": { - "capability": "unsupported", + "capability": "supported", "validation": "validated-test", - "expectedFailure": true, - "mechanism": "CLI writes `config.json.teamRoot`; state-mcp resolves `mode: remote`; canonical step 3 loads `team.md` from the team root.", + "mechanism": "CLI writes `config.json.teamRoot`; state-mcp resolves `mode: remote`; canonical step 3 loads `team.md` from the team root; the digest's **Linked team** rule reads team files only through `squad_state`.", "evidence": [ "validated-test:test/resolution.test.ts::*", + "validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state", "validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)", "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", "static-only" - ], - "notes": "Expected-fail digest gap (per-host it.fails in test/clients.test.ts): The runtime path works (state-mcp walks up to the root `.squad/` / follows `teamRoot`; see the live run where recorded), but the projected digest carries no team-root section, so the coordinator is not told how resolution works. Unsupported on this host until the section ships." + ] }, "claude": { - "capability": "unsupported", - "validation": "validated-test", - "expectedFailure": true, - "mechanism": "The state bridge follows `config.json.teamRoot`, so the host coordinator read the team-root roster via `squad_state_read` and did not enter Init Mode, even though the digest has no team-root section.", + "capability": "supported", + "validation": "validated-live", + "mechanism": "`squad link` / `init --mode remote` write `config.json.teamRoot`; `squad init` and `squad upgrade` then scaffold no local roster, routing, charters or casting, and project the member subagents from the linked team (they read their charters with `squad_state_read`). The state bridge follows `teamRoot`, and the digest's **Linked team** rule reads team files only through `squad_state`.", "evidence": [ + "validated-test:test/claude-linked-team.test.ts::init and upgrade use the linked team: no local roster, members projected from the team", "validated-test:test/resolution.test.ts::*", + "validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state", "validated-manual:lab:satellite-remote@2026-10-22", "validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)", "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", - "static-only" + "static-only", + "validated-manual:lab:satellite-linked-members@2026-11-13" ], - "notes": "Live: roster served from `..\\base`; no casting attempted. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): The runtime path works (state-mcp walks up to the root `.squad/` / follows `teamRoot`; see the live run where recorded), but the projected digest carries no team-root section, so the coordinator is not told how resolution works. Unsupported on this host until the section ships." + "notes": "Live (2026-11-13): roster and routing read from the linked team via `squad_state_read`, API work routed to Dallas, and the projected `dallas` subagent read its charter through `squad_state`; no local team files, no denials. Earlier run: roster served from `..\\\\base`; no casting attempted." }, "kiro": { - "capability": "unsupported", + "capability": "supported", "validation": "validated-test", - "expectedFailure": true, - "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator.", + "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`.", "evidence": [ "validated-test:test/resolution.test.ts::*", + "validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state", "validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)", "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", "static-only" @@ -2837,26 +2838,26 @@ "notes": "Finding F4 candidate — validate. Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships." }, "opencode": { - "capability": "unsupported", + "capability": "supported", "validation": "validated-test", - "expectedFailure": true, - "mechanism": "The state bridge follows `config.json.teamRoot`, so the host coordinator read the team-root roster via `squad_state_read` and did not enter Init Mode, even though the digest has no team-root section.", + "mechanism": "The state bridge follows `config.json.teamRoot`, so the host coordinator read the team-root roster via `squad_state_read` and did not enter Init Mode, even though the digest has no team-root section; the digest's **Linked team** rule reads team files only through `squad_state`.", "evidence": [ "validated-test:test/resolution.test.ts::*", + "validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state", "validated-manual:lab:satellite-remote@2026-10-22", "validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)", "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", "static-only" ], - "notes": "Live: roster served from `..\\base`; no casting attempted. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): The runtime path works (state-mcp walks up to the root `.squad/` / follows `teamRoot`; see the live run where recorded), but the projected digest carries no team-root section, so the coordinator is not told how resolution works. Unsupported on this host until the section ships." + "notes": "Live: roster served from `..\\base`; no casting attempted." }, "cursor": { - "capability": "unsupported", + "capability": "supported", "validation": "validated-test", - "expectedFailure": true, - "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator.", + "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`.", "evidence": [ "validated-test:test/resolution.test.ts::*", + "validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state", "validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)", "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", "static-only" @@ -2864,12 +2865,12 @@ "notes": "Finding F4 candidate — validate. Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships." }, "codex": { - "capability": "unsupported", + "capability": "supported", "validation": "validated-test", - "expectedFailure": true, - "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator.", + "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`.", "evidence": [ "validated-test:test/resolution.test.ts::*", + "validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state", "validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)", "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", "static-only" @@ -2877,12 +2878,12 @@ "notes": "Finding F4 candidate — validate. No Codex binary or ChatGPT/API auth on the reference machine; static-only. Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships." }, "antigravity": { - "capability": "unsupported", + "capability": "supported", "validation": "validated-test", - "expectedFailure": true, - "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator.", + "mechanism": "State tools follow `teamRoot` transparently, but the digest reads `.squad/team.md` literally and never mentions `teamRoot`/`stateLocation`, so a satellite repo may look like Init Mode to the host coordinator; the digest's **Linked team** rule reads team files only through `squad_state`.", "evidence": [ "validated-test:test/resolution.test.ts::*", + "validated-test:test/claude-linked-team.test.ts::every coordinator digest reads a linked team through squad_state", "validated-test:test/squad-20-host-feature-matrix.test.ts::state-mcp follows config.json teamRoot from a satellite repo (remote mode)", "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", "static-only" @@ -5051,28 +5052,23 @@ "outcome": "pass", "observedTools": [], "notes": "Claude Code 2.1.318 interactive TUI driven in a pty, clean environment. With the plugin (`--plugin-dir`): after the folder-trust dialog there is no MCP approval prompt, `/mcp` lists `plugin:squad:squad_state` connected (15 tools), and the session starts as `@squad:squad` with no `--agent`. Control, project `.mcp.json` only: after the folder-trust dialog Claude Code shows \"New MCP server found in this project: squad_state\" with \"Continue without using this MCP server\" preselected." + }, + { + "host": "claude", + "scenario": "satellite-linked-members", + "date": "2026-11-13", + "outcome": "pass", + "observedTools": [ + "mcp__squad_state__squad_state_read", + "Agent" + ], + "notes": "Satellite linked to a team repo with Ripley and Dallas: roster/routing from the team via squad_state_read, `dallas` subagent read agents/dallas/charter.md via squad_state_read; no local team files written." } ], "expectedFailures": [ { "featureId": "monorepo-team-root", - "test": "test/clients.test.ts::digest gap (expected until the stack adds it): team-root resolution (teamRoot / stateLocation / TEAM_ROOT)", - "kind": "it.fails", - "perHost": true, - "hosts": [ - "copilot", - "claude", - "kiro", - "opencode", - "cursor", - "codex", - "antigravity" - ], - "meaning": "The projected digest lacks this canonical section on the listed hosts; one it.fails per host (title suffixed `[]`) turns red the moment a stack adds it. Every listed cell is `unsupported` for that host until then." - }, - { - "featureId": "dual-root-remote-mode", - "test": "test/clients.test.ts::digest gap (expected until the stack adds it): team-root resolution (teamRoot / stateLocation / TEAM_ROOT)", + "test": "test/clients.test.ts::digest gap (expected until the stack adds it): team-root resolution (stateLocation / TEAM_ROOT)", "kind": "it.fails", "perHost": true, "hosts": [ diff --git a/test/claude-linked-team.test.ts b/test/claude-linked-team.test.ts new file mode 100644 index 000000000..c79f0db38 --- /dev/null +++ b/test/claude-linked-team.test.ts @@ -0,0 +1,105 @@ +/** + * A project linked to a team repository (remote mode: `teamRoot` in `.squad/config.json`) uses + * that team in Claude Code (#2487 satellite scenario). `squad init` / `squad upgrade` used to give + * it a placeholder local team (empty roster, routing, built-ins, casting), and the Claude member + * subagents came from that empty local team instead of the linked one. + */ +import { afterEach, beforeEach, describe, expect, it, 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 { projectClaudeMembers } from '../packages/squad-sdk/src/client/adapters/claude-members.js'; +import { buildCoordinatorInstructions } from '../packages/squad-sdk/src/client/adapters/coordinator.js'; + +let base: string; +let team: string; +let app: string; +const prevEnv = { ni: process.env['SQUAD_NONINTERACTIVE'], nc: process.env['NO_COLOR'] }; + +function snapshot(dir: string): Record { + const out: Record = {}; + const walk = (rel: string) => { + for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) { + if (e.name === '.git') continue; + const r = path.join(rel, e.name); + if (e.isDirectory()) walk(r); + else out[r] = fs.readFileSync(path.join(dir, r), 'utf8'); + } + }; + walk('.'); + return out; +} + +beforeEach(() => { + process.env['SQUAD_NONINTERACTIVE'] = '1'; + process.env['NO_COLOR'] = '1'; + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'squad-linked-team-'))); + team = path.join(base, 'team'); + app = path.join(base, 'app'); + for (const d of [team, app]) { + fs.mkdirSync(d); + execFileSync('git', ['init', '--quiet'], { cwd: d }); + } +}); +afterEach(() => { + vi.restoreAllMocks(); + process.env['SQUAD_NONINTERACTIVE'] = prevEnv.ni; + process.env['NO_COLOR'] = prevEnv.nc; + if (prevEnv.ni === undefined) delete process.env['SQUAD_NONINTERACTIVE']; + if (prevEnv.nc === undefined) delete process.env['NO_COLOR']; + fs.rmSync(base, { recursive: true, force: true }); +}); + +describe('Claude Code in a project linked to a team repository', () => { + it('init and upgrade use the linked team: no local roster, members projected from the team', async () => { + const { runInit } = await import('../packages/squad-cli/src/cli/core/init.js'); + const { runUpgrade } = await import('../packages/squad-cli/src/cli/core/upgrade.js'); + const { runCastAdd } = await import('../packages/squad-cli/src/cli/commands/cast.js'); + const { writeRemoteConfig } = await import('../packages/squad-cli/src/cli/commands/init-remote.js'); + + await runInit(team, { includeWorkflows: false, includeVscodeDefault: false }); + await runCastAdd(team, { name: 'Dallas', role: 'Backend Dev' }); + const teamBefore = snapshot(team); + + writeRemoteConfig(app, '../team'); + await runInit(app, { clients: ['claude'], includeWorkflows: false, includeVscodeDefault: false }); + await runUpgrade(app, { clients: ['claude'] }); + + const local = path.join(app, '.squad'); + for (const f of ['team.md', 'routing.md', 'decisions.md', 'ceremonies.md', 'agents', 'casting', 'identity', 'rai']) { + expect(fs.existsSync(path.join(local, f)), f).toBe(false); + } + // Project-local state and the launcher stay. + for (const f of ['config.json', 'squad.mjs', 'decisions/inbox']) expect(fs.existsSync(path.join(local, f)), f).toBe(true); + + const dallas = fs.readFileSync(path.join(app, '.claude/agents/dallas.md'), 'utf8'); + expect(dallas).toMatch(/linked team repository/); + expect(dallas).toContain('`agents/dallas/charter.md`'); + expect(fs.readFileSync(path.join(app, '.claude/agents/squad.md'), 'utf8')).toMatch(/\*\*Linked team:\*\*/); + + // The team repository is never written by the linked project's init or upgrade. + expect(snapshot(team)).toEqual(teamBefore); + }, 120_000); + + it('projects members from ctx.teamDir and reads charters only inside it', () => { + const teamSquad = path.join(team, '.squad'); + fs.mkdirSync(path.join(teamSquad, 'agents', 'dallas'), { recursive: true }); + fs.writeFileSync(path.join(teamSquad, 'agents', 'dallas', 'charter.md'), '# Dallas — Backend Dev\n\n## Identity\n- **Role:** Backend Dev\n'); + fs.mkdirSync(path.join(base, 'outside')); + fs.writeFileSync(path.join(base, 'outside', 'charter.md'), '# Evil\n'); + fs.symlinkSync(path.join(base, 'outside'), path.join(teamSquad, 'agents', 'evil'), process.platform === 'win32' ? 'junction' : 'dir'); + const files = projectClaudeMembers({ root: app, mcp: { command: 'squad', args: ['state-mcp'] }, teamDir: teamSquad }, '', 'gen'); + expect(files.map((f) => f.path)).toEqual(['.claude/agents/dallas.md']); + // Without teamDir the (empty) project directory is used, as before. + expect(projectClaudeMembers({ root: app, mcp: { command: 'squad', args: ['state-mcp'] } }, '', 'gen')).toEqual([]); + }); + + it('every coordinator digest reads a linked team through squad_state', () => { + for (const host of ['copilot', 'claude', 'kiro', 'opencode', 'cursor', 'codex', 'antigravity'] as const) { + expect(buildCoordinatorInstructions(host, '.squad', app)).toMatch(/\*\*Linked team:\*\*.*squad_state_read/); + } + }); +}); diff --git a/test/clients.test.ts b/test/clients.test.ts index ee5e1407c..baf01571f 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -312,7 +312,9 @@ describe('coordinator digest vs. host feature matrix', () => { // The optional fourth entry lists hosts whose digest has closed the gap: they assert it instead of `it.fails`. const gaps: Array<[string, RegExp, string, ClientId[]?]> = [ - ['team-root resolution (teamRoot / stateLocation / TEAM_ROOT)', /teamRoot|stateLocation|TEAM_ROOT/, 'monorepo-team-root, dual-root-remote-mode'], + // Closed (#2487): every digest's "Linked team" rule (step 1) reads a linked team repository through squad_state. + ['linked team repository (teamRoot)', /\*\*Linked team:\*\*.*`teamRoot`.*squad_state_read/, 'dual-root-remote-mode', [...CLIENT_IDS]], + ['team-root resolution (stateLocation / TEAM_ROOT)', /stateLocation|TEAM_ROOT/, 'monorepo-team-root'], ['Azure DevOps issue tracking (az boards / ADO)', /az boards|Azure DevOps/, 'azure-devops (F3)'], ['GitLab issue tracking (glab)', /glab|GitLab/, 'gitlab-issues (F2)'], // Claude (#2442): member subagent frontmatter plus the per-spawn `model` rules in its dispatch section. From 3ab458bea905e741a6dd7db8b7744e3e434d941d Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Fri, 13 Nov 2026 20:15:19 -0800 Subject: [PATCH 2/3] fix(remote): address linked-team review - squad client list expects the linked team's member files - a linked member's charter is carried in its subagent file; history via squad_state_read only when its authored tool list allows (never widened) - effort overrides come from the project's own config - teamRoot "." is not a linked team in the coordinator rule - init advertises the linked team's cast in Copilot's agent file - the interactive shell reads the linked team's roster Co-Authored-By: Claude Opus 5.5 --- packages/squad-cli/src/cli/commands/client.ts | 4 +- packages/squad-cli/src/cli/core/clients.ts | 2 +- packages/squad-cli/src/cli/core/init.ts | 4 +- packages/squad-cli/src/cli/shell/lifecycle.ts | 6 ++- .../src/client/adapters/claude-members.ts | 20 +++++++-- .../src/client/adapters/coordinator.ts | 2 +- packages/squad-sdk/src/config/init.ts | 4 +- plugins/squad/agents/squad.md | 2 +- test/claude-linked-team.test.ts | 44 +++++++++++++++++-- 9 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/squad-cli/src/cli/commands/client.ts b/packages/squad-cli/src/cli/commands/client.ts index ef6f2663a..f9dfe2ebd 100644 --- a/packages/squad-cli/src/cli/commands/client.ts +++ b/packages/squad-cli/src/cli/commands/client.ts @@ -19,7 +19,7 @@ import { type ClientId, type ProjectionFile, } from '@bradygaster/squad-sdk/client'; -import { readClientFlag, readConfiguredClients, readUnknownConfiguredClients, syncClients, reportClientResults, parseClients } from '../core/clients.js'; +import { linkedTeamDir, readClientFlag, readConfiguredClients, readUnknownConfiguredClients, syncClients, reportClientResults, parseClients } from '../core/clients.js'; import { detectSquadDir } from '../core/detect-squad-dir.js'; import { projectSquadStateMcpSpec, resolveCommittedSquadStateMcpSpec } from '../core/mcp-spec.js'; import { getPackageVersion } from '../core/version.js'; @@ -62,6 +62,8 @@ export async function listClients(dest: string): Promise { root: dest, mcp: { command: spec.command, args: spec.args }, squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad', + // A linked team's members are projected too, so they are expected here as well. + ...linkedTeamDir(dest), }; const clients: ClientStatus[] = CLIENT_IDS diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index e5aaab30d..1ef44085a 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -166,7 +166,7 @@ export function linkedTeamSquadDir(dest: string): string | null { } /** `{ teamDir }` when `dest` is linked to a team repository (remote mode), else nothing. */ -function linkedTeamDir(dest: string): { teamDir?: string } { +export function linkedTeamDir(dest: string): { teamDir?: string } { const teamDir = linkedTeamSquadDir(dest); return teamDir ? { teamDir } : {}; } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 9c4160d08..621306fd9 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -323,12 +323,14 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi } // A project linked to a team repository (`squad link`, `init --mode remote`) uses that team. - const linkedTeam = linkedTeamSquadDir(dest) !== null; + const linkedTeamDir = linkedTeamSquadDir(dest); + const linkedTeam = linkedTeamDir !== null; // Build SDK options const initOptions: InitOptions = { teamRoot: dest, linkedTeam, + ...(linkedTeamDir ? { linkedTeamDir } : {}), agentFileRoot, projectName: path.basename(dest) || 'my-project', agents: [ diff --git a/packages/squad-cli/src/cli/shell/lifecycle.ts b/packages/squad-cli/src/cli/shell/lifecycle.ts index 2580eb9a1..b8a7e02a6 100644 --- a/packages/squad-cli/src/cli/shell/lifecycle.ts +++ b/packages/squad-cli/src/cli/shell/lifecycle.ts @@ -10,6 +10,7 @@ import path from 'node:path'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { resolveStateDir } from '../core/effective-squad-dir.js'; +import { linkedTeamSquadDir } from '../core/clients.js'; import { SessionRegistry } from './sessions.js'; import { ShellRenderer } from './render.js'; import type { ShellState, ShellMessage } from './types.js'; @@ -75,8 +76,9 @@ export class ShellLifecycle { throw err; } - // Resolve effective state dir (external when externalized) - const squadDir = resolveStateDir(localSquadDir); + // The team's files come from a linked team repository when there is one (remote mode), then + // from external state when externalized. Project-local files (.init-prompt) stay in localSquadDir. + const squadDir = resolveStateDir(linkedTeamSquadDir(this.options.teamRoot) ?? localSquadDir); const teamPath = path.join(squadDir, 'team.md'); const teamContent = await storage.read(teamPath); diff --git a/packages/squad-sdk/src/client/adapters/claude-members.ts b/packages/squad-sdk/src/client/adapters/claude-members.ts index bd4118222..c54a8f4b5 100644 --- a/packages/squad-sdk/src/client/adapters/claude-members.ts +++ b/packages/squad-sdk/src/client/adapters/claude-members.ts @@ -173,10 +173,22 @@ export function renderClaudeMember( const charterPath = `${squadDir}/agents/${id}/charter.md`; const historyPath = `${squadDir}/agents/${id}/history.md`; + // A linked team's files are outside this project, so the charter is carried in this file (it is + // regenerated on every sync). The history is read through squad_state only when the authored tool + // list allows it; the list is never widened. + const canReadState = + !host.tools || host.tools.some((t) => t === `${mcpPrefix}squad_state_read` || t === `${mcpPrefix}*` || t === mcpPrefix.replace(/__$/, '')); + if (linked && !canReadState) { + notes.push(`History not readable (the Tools list has no \`${mcpPrefix}squad_state_read\`; the spawn prompt must carry it)`); + } const readCharter = linked - ? `This project uses a linked team repository, so read your charter and history with \`squad_state_read\` (keys \`agents/${id}/charter.md\` and \`agents/${id}/history.md\`), not from \`${squadDir}/\`. ` + ? `This project uses a linked team repository. Your charter from it is below. ` + + (canReadState + ? `Read your history with \`squad_state_read\` (key \`agents/${id}/history.md\`), not from \`${squadDir}/\`. ` + : `Your history is not available to you here: rely on the spawn prompt. `) : `Before you start, read your charter at \`${charterPath}\` and your history at \`${historyPath}\`. ` + `If the spawn prompt gives a TEAM ROOT, resolve these paths under it instead. `; + const linkedCharter = linked ? `\n## Your charter (linked team repository)\n\n${charter.replace(/\r\n?/g, '\n').trim()}\n` : ''; const notesBlock = notes.length > 0 ? `\n\n` @@ -189,7 +201,8 @@ export function renderClaudeMember( readCharter + `Your charter is authoritative: follow its responsibilities and boundaries, and the task in the spawn prompt.\n\n` + `Squad dispatches specialists only from the coordinator: do not spawn subagents of your own. ` + - `Read and write shared team state through the \`squad_state\` MCP tools (\`${mcpPrefix}*\`) when they are available.\n` + `Read and write shared team state through the \`squad_state\` MCP tools (\`${mcpPrefix}*\`) when they are available.\n` + + linkedCharter ); } @@ -210,7 +223,8 @@ export function projectClaudeMembers( const agentsAbs = path.join(squadAbs, 'agents'); const routing = readRouting(squadAbs); const ids = memberIds(agentsAbs); - const effortConfig = readEffortConfig(squadAbs); + // Effort overrides are this project's own settings, even when the team is linked. + const effortConfig = readEffortConfig(path.resolve(ctx.root, squadDir)); const files: ProjectionFile[] = []; for (const id of ids) { let charter: string; diff --git a/packages/squad-sdk/src/client/adapters/coordinator.ts b/packages/squad-sdk/src/client/adapters/coordinator.ts index cc5e949d9..a0192797e 100644 --- a/packages/squad-sdk/src/client/adapters/coordinator.ts +++ b/packages/squad-sdk/src/client/adapters/coordinator.ts @@ -124,7 +124,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep - **Phase 2 (create).** If you can run shell commands, add EVERY member with the CLI, one call each: \`squad cast --name --role ""\` (or, when \`squad\` is not on PATH, \`node ${S}/squad.mjs cast --name --role ""\`; not \`npx squad\`, which may fetch an old published version and needs an approval prompt) (it writes the charter, history, the \`team.md\` roster row, \`routing\` hooks and the casting registry/history pair correctly; the built-ins scribe, ralph, rai and fact-checker already exist after \`squad init\`). Then write/adjust \`${S}/routing.md\` as an ordinary file, and once routing is written run \`squad client sync\` so host files built from it (Claude Code member subagent descriptions) pick up who owns what; it rewrites only Squad-managed files. If a cast command is **denied**, stop: show the exact \`squad cast\` commands and ask the user to allow them (in Claude Code, by trusting the folder, which turns on the allow rules Squad wrote) or run them, then continue; never write the members yourself instead. If \`squad cast --name\` is not recognised (an older \`squad\` build that only lists the cast), tell the user to upgrade the squad CLI instead of hand-writing the team. NEVER hand-write or edit anything under \`${S}/casting/\` (\`registry.json\`, \`history.json\`, \`registry-history.commit.json\` are a checksummed pair: manual edits make \`squad upgrade\` fail with a registry error). If you cannot run shell commands, write \`team.md\` (a markdown table with \`Name\`, \`Role\`, \`Charter\`, \`Status\` columns; the Coordinator row stays separate), \`${S}/agents//charter.md\` and \`history.md\` as ordinary files, leave \`${S}/casting/\` untouched and tell the user to run \`squad cast --name --role ""\` for each member to register them. - **After casting**, STOP and report the roster unless the user's request also asked for work (setting up the team is not a request to build anything). - **Scratch space.** Use \`${S}/scratch/\` (inside the workspace) for temporary files; never write outside the workspace directory (hosts reject external-directory access). -1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when \`${S}/config.json\` sets \`teamRoot\`, the project uses a team repository: team files (\`team.md\`, \`routing.md\`, charters, decisions) live there, so read them with \`squad_state_read\` / \`squad_state_list\` (which serve that team), never from \`${S}/\` directly, and never create a local roster. When a recorded decision bears on the request, say so in your routing line (e.g. "Per the team decision of : ") and pass it to the members and the reviewer. +1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when \`${S}/config.json\` sets \`teamRoot\` to a path other than \`.\` (\`.\` means no linked team), the project uses a team repository: team files (\`team.md\`, \`routing.md\`, charters, decisions) live there, so read them with \`squad_state_read\` / \`squad_state_list\` (which serve that team), never from \`${S}/\` directly, and never create a local roster. When a recorded decision bears on the request, say so in your routing line (e.g. "Per the team decision of : ") and pass it to the members and the reviewer. 2. **Route by ownership.** Match the request to the member whose role owns it per \`routing.md\`. Every work item has exactly ONE accountable owner (others contribute/review). If nobody owns it, ask the user or propose a new member. Named requests ("Dallas, fix X") go to that member. 3. **Spawn specialists with full context.** Each specialist prompt must include: the member's charter (\`${S}/agents//charter.md\`), the task with explicit deliverables AND exclusions (no silent scope expansion; approved expansion is routed as separate work), relevant decisions, the working directory, and the \`squad_state\` usage rules below. Name tasks ": " (e.g. "Dallas: build server") and never do the specialist's work yourself. 4. **Mutable state goes through the \`squad_state\` MCP server** (backend). Use \`squad_state_read\`, \`squad_state_list\`, \`squad_state_write\`, \`squad_state_append\`, \`squad_state_delete\` for runtime state (decisions inbox, agent history, logs, sessions, scratch) and \`squad_decide\` to record decisions. Never edit those files directly when the MCP backend is available. **Static team config is the exception:** \`team.md\`, \`routing.md\`, \`config.json\`, charters (\`${S}/agents//charter.md\`), templates and skills are NOT mutable state; the state tools reject writes to them, so create and edit them as ordinary files (\`squad_state_read\` / \`squad_state_list\` can still read them). **Tool names are host-prefixed:** hosts expose these tools as e.g. \`mcp__squad_state__squad_state_read\` (Claude Code), \`squad_state_squad_state_read\` (opencode) or \`@squad_state/squad_state_read\` (Kiro); always call the name actually present in your tool list. **Files vs directories:** \`squad_state_read\` takes a FILE key (e.g. \`decisions.md\`, \`agents/dallas/history.md\`); for a directory (\`identity\`, \`templates\`, \`decisions/inbox\`, \`agents\`) call \`squad_state_list\` instead, never read it (that fails with EISDIR). diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 0fd21b331..417a2d5e4 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -132,6 +132,8 @@ export interface InitOptions { * built-ins' policies), which live in that repository. Project-local state directories stay. */ linkedTeam?: boolean; + /** The linked team's squad directory (with `linkedTeam`): its cast is advertised in the agent file. */ + linkedTeamDir?: string; /** Project name */ projectName: string; /** Project description (optional) */ @@ -1487,7 +1489,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre createdFiles.push(toRelativePath(agentFile)); // Advertise the (possibly still empty) cast to outer coordinators (#1608). try { - syncTeamCapabilities({ squadDir, agentFile, storage }); + syncTeamCapabilities({ squadDir: (options.linkedTeam && options.linkedTeamDir) || squadDir, agentFile, storage }); } catch (error) { warnings.push( `Could not refresh squad.agent.md team capabilities: ${ diff --git a/plugins/squad/agents/squad.md b/plugins/squad/agents/squad.md index 3dbede0ca..93e4b8d71 100644 --- a/plugins/squad/agents/squad.md +++ b/plugins/squad/agents/squad.md @@ -13,7 +13,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep ## Core protocol (host-agnostic) 0. **Init Mode (only when there is truly no team).** Decide this with `squad_state_read` of `team.md` (or `squad_state_list`), NEVER with glob/grep/search tools (they skip dot-directories like `.squad/`). If `team.md` is readable and lists members, a team exists: NEVER overwrite or re-cast it. Only if `team.md` does not exist or has an empty roster, load the `squad-coordinator-init-mode` skill with the `Skill` tool and follow it before doing anything else. Its essentials: cast EVERY member with Bash, one call each, `squad cast --name --role ""` (if `squad` is not on PATH, `node .squad/squad.mjs cast --name --role ""`), where the role is a short title such as "Tester" or "Node.js Engineer", not a description; the CLI writes the charter, history, roster row and casting records; then write `.squad/routing.md`; then run `squad client sync` (or `node .squad/squad.mjs client sync`), which writes each member's subagent in `.claude/agents/`. NEVER write or edit files under `.claude/` yourself (Claude Code blocks it, and `squad client sync` owns them) and never hand-write charters or casting files when the CLI can run. -1. **Load team context first.** Read `.squad/team.md` (roster), `.squad/routing.md` (who owns what), `.squad/decisions.md` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when `.squad/config.json` sets `teamRoot`, the project uses a team repository: team files (`team.md`, `routing.md`, charters, decisions) live there, so read them with `squad_state_read` / `squad_state_list` (which serve that team), never from `.squad/` directly, and never create a local roster. +1. **Load team context first.** Read `.squad/team.md` (roster), `.squad/routing.md` (who owns what), `.squad/decisions.md` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when `.squad/config.json` sets `teamRoot` to a path other than `.` (`.` means no linked team), the project uses a team repository: team files (`team.md`, `routing.md`, charters, decisions) live there, so read them with `squad_state_read` / `squad_state_list` (which serve that team), never from `.squad/` directly, and never create a local roster. 2. **Route by ownership.** Match the request to the member whose role owns it per `routing.md`. Every work item has exactly ONE accountable owner (others contribute/review). If nobody owns it, ask the user or propose a new member. Named requests ("Dallas, fix X") go to that member. 3. **Spawn specialists with full context.** Each specialist prompt must include: the member's charter (`.squad/agents//charter.md`), the task with explicit deliverables AND exclusions (no silent scope expansion; approved expansion is routed as separate work), relevant decisions, the working directory, and the `squad_state` usage rules below. Name tasks ": " (e.g. "Dallas: build server") and never do the specialist's work yourself. 4. **Mutable state goes through the `squad_state` MCP server** (backend). Use `squad_state_read`, `squad_state_list`, `squad_state_write`, `squad_state_append`, `squad_state_delete` for runtime state (decisions inbox, agent history, logs, sessions, scratch) and `squad_decide` to record decisions. Never edit those files directly when the MCP backend is available. **Static team config is the exception:** `team.md`, `routing.md`, `config.json`, charters (`.squad/agents//charter.md`), templates and skills are NOT mutable state; the state tools reject writes to them, so create and edit them as ordinary files (`squad_state_read` / `squad_state_list` can still read them). **Tool names are host-prefixed:** hosts expose these tools as e.g. `mcp__squad_state__squad_state_read` (Claude Code), `squad_state_squad_state_read` (opencode) or `@squad_state/squad_state_read` (Kiro); always call the name actually present in your tool list. **Files vs directories:** `squad_state_read` takes a FILE key (e.g. `decisions.md`, `agents/dallas/history.md`); for a directory (`identity`, `templates`, `decisions/inbox`, `agents`) call `squad_state_list` instead, never read it (that fails with EISDIR). diff --git a/test/claude-linked-team.test.ts b/test/claude-linked-team.test.ts index c79f0db38..d3f7d4883 100644 --- a/test/claude-linked-team.test.ts +++ b/test/claude-linked-team.test.ts @@ -66,6 +66,9 @@ describe('Claude Code in a project linked to a team repository', () => { writeRemoteConfig(app, '../team'); await runInit(app, { clients: ['claude'], includeWorkflows: false, includeVscodeDefault: false }); + // Right after init, Copilot's agent file advertises the linked team's cast. + const agentAfterInit = fs.readFileSync(path.join(app, '.github/agents/squad.agent.md'), 'utf8'); + expect(agentAfterInit.split('')[1] ?? '').toMatch(/dallas/i); await runUpgrade(app, { clients: ['claude'] }); const local = path.join(app, '.squad'); @@ -77,7 +80,23 @@ describe('Claude Code in a project linked to a team repository', () => { const dallas = fs.readFileSync(path.join(app, '.claude/agents/dallas.md'), 'utf8'); expect(dallas).toMatch(/linked team repository/); - expect(dallas).toContain('`agents/dallas/charter.md`'); + // The charter travels in the file (it lives outside this project); history comes through squad_state. + expect(dallas).toMatch(/## Your charter \(linked team repository\)\n\n# Dallas/); + expect(dallas).toContain('`agents/dallas/history.md`'); + + // `squad client list` expects the linked members too: a deleted member file is reported. + const { listClients } = await import('../packages/squad-cli/src/cli/commands/client.js'); + fs.rmSync(path.join(app, '.claude/agents/dallas.md')); + const claude = (await listClients(app)).clients.find((c) => c.id === 'claude')!; + expect(claude.files.find((f) => f.path === '.claude/agents/dallas.md')).toEqual({ path: '.claude/agents/dallas.md', present: false }); + + // The interactive shell finds the linked team's members. + const { ShellLifecycle } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); + const { SessionRegistry } = await import('../packages/squad-cli/src/cli/shell/sessions.js'); + const { ShellRenderer } = await import('../packages/squad-cli/src/cli/shell/render.js'); + const lifecycle = new ShellLifecycle({ teamRoot: app, renderer: new ShellRenderer(), registry: new SessionRegistry() }); + await lifecycle.initialize(); + expect(lifecycle.getDiscoveredAgents().map((a) => a.name)).toContain('Dallas'); expect(fs.readFileSync(path.join(app, '.claude/agents/squad.md'), 'utf8')).toMatch(/\*\*Linked team:\*\*/); // The team repository is never written by the linked project's init or upgrade. @@ -97,9 +116,28 @@ describe('Claude Code in a project linked to a team repository', () => { expect(projectClaudeMembers({ root: app, mcp: { command: 'squad', args: ['state-mcp'] } }, '', 'gen')).toEqual([]); }); - it('every coordinator digest reads a linked team through squad_state', () => { + it('every coordinator digest reads a linked team through squad_state; `teamRoot: "."` is not linked', () => { for (const host of ['copilot', 'claude', 'kiro', 'opencode', 'cursor', 'codex', 'antigravity'] as const) { - expect(buildCoordinatorInstructions(host, '.squad', app)).toMatch(/\*\*Linked team:\*\*.*squad_state_read/); + expect(buildCoordinatorInstructions(host, '.squad', app)).toMatch(/\*\*Linked team:\*\*.*sets `teamRoot` to a path other than `\.` \(`\.` means no linked team\).*squad_state_read/); } }); + + it("never widens a linked member's authored tool list, and keeps the project's effort overrides", () => { + const teamSquad = path.join(team, '.squad'); + fs.mkdirSync(path.join(teamSquad, 'agents', 'dallas'), { recursive: true }); + fs.writeFileSync( + path.join(teamSquad, 'agents', 'dallas', 'charter.md'), + '# Dallas — Backend Dev\n\n## Identity\n- **Role:** Backend Dev\n\n## X-squad-host-claude\n- **Tools:** Read, Grep, Glob\n', + ); + fs.writeFileSync(path.join(teamSquad, 'config.json'), JSON.stringify({ defaultReasoningEffort: 'high' })); + fs.mkdirSync(path.join(app, '.squad'), { recursive: true }); + fs.writeFileSync(path.join(app, '.squad', 'config.json'), JSON.stringify({ teamRoot: '../team', agentReasoningEffortOverrides: { dallas: 'low' } })); + const [file] = projectClaudeMembers({ root: app, mcp: { command: 'squad', args: ['state-mcp'] }, teamDir: teamSquad }, '', 'gen'); + const text = (file as { content: string }).content; + expect(text).toMatch(/^tools: Read, Grep, Glob$/m); + expect(text).toMatch(/^effort: low$/m); + expect(text).toMatch(/## Your charter \(linked team repository\)/); + expect(text).toMatch(/History not readable/); + expect(text).not.toMatch(/Read your history with/); + }); }); From aba5f813da5dad82644ef0fa1cfc1d27693f2a2f Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Fri, 13 Nov 2026 20:24:52 -0800 Subject: [PATCH 3/3] chore(plugin): regenerate after rebase on #2496 Co-Authored-By: Claude Opus 5.5 --- plugins/squad/agents/squad.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/squad/agents/squad.md b/plugins/squad/agents/squad.md index 93e4b8d71..c5c3cd849 100644 --- a/plugins/squad/agents/squad.md +++ b/plugins/squad/agents/squad.md @@ -13,21 +13,21 @@ You are the Squad coordinator. You route work to a team of specialists and keep ## Core protocol (host-agnostic) 0. **Init Mode (only when there is truly no team).** Decide this with `squad_state_read` of `team.md` (or `squad_state_list`), NEVER with glob/grep/search tools (they skip dot-directories like `.squad/`). If `team.md` is readable and lists members, a team exists: NEVER overwrite or re-cast it. Only if `team.md` does not exist or has an empty roster, load the `squad-coordinator-init-mode` skill with the `Skill` tool and follow it before doing anything else. Its essentials: cast EVERY member with Bash, one call each, `squad cast --name --role ""` (if `squad` is not on PATH, `node .squad/squad.mjs cast --name --role ""`), where the role is a short title such as "Tester" or "Node.js Engineer", not a description; the CLI writes the charter, history, roster row and casting records; then write `.squad/routing.md`; then run `squad client sync` (or `node .squad/squad.mjs client sync`), which writes each member's subagent in `.claude/agents/`. NEVER write or edit files under `.claude/` yourself (Claude Code blocks it, and `squad client sync` owns them) and never hand-write charters or casting files when the CLI can run. -1. **Load team context first.** Read `.squad/team.md` (roster), `.squad/routing.md` (who owns what), `.squad/decisions.md` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when `.squad/config.json` sets `teamRoot` to a path other than `.` (`.` means no linked team), the project uses a team repository: team files (`team.md`, `routing.md`, charters, decisions) live there, so read them with `squad_state_read` / `squad_state_list` (which serve that team), never from `.squad/` directly, and never create a local roster. +1. **Load team context first.** Read `.squad/team.md` (roster), `.squad/routing.md` (who owns what), `.squad/decisions.md` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. **Linked team:** when `.squad/config.json` sets `teamRoot` to a path other than `.` (`.` means no linked team), the project uses a team repository: team files (`team.md`, `routing.md`, charters, decisions) live there, so read them with `squad_state_read` / `squad_state_list` (which serve that team), never from `.squad/` directly, and never create a local roster. When a recorded decision bears on the request, say so in your routing line (e.g. "Per the team decision of : ") and pass it to the members and the reviewer. 2. **Route by ownership.** Match the request to the member whose role owns it per `routing.md`. Every work item has exactly ONE accountable owner (others contribute/review). If nobody owns it, ask the user or propose a new member. Named requests ("Dallas, fix X") go to that member. 3. **Spawn specialists with full context.** Each specialist prompt must include: the member's charter (`.squad/agents//charter.md`), the task with explicit deliverables AND exclusions (no silent scope expansion; approved expansion is routed as separate work), relevant decisions, the working directory, and the `squad_state` usage rules below. Name tasks ": " (e.g. "Dallas: build server") and never do the specialist's work yourself. 4. **Mutable state goes through the `squad_state` MCP server** (backend). Use `squad_state_read`, `squad_state_list`, `squad_state_write`, `squad_state_append`, `squad_state_delete` for runtime state (decisions inbox, agent history, logs, sessions, scratch) and `squad_decide` to record decisions. Never edit those files directly when the MCP backend is available. **Static team config is the exception:** `team.md`, `routing.md`, `config.json`, charters (`.squad/agents//charter.md`), templates and skills are NOT mutable state; the state tools reject writes to them, so create and edit them as ordinary files (`squad_state_read` / `squad_state_list` can still read them). **Tool names are host-prefixed:** hosts expose these tools as e.g. `mcp__squad_state__squad_state_read` (Claude Code), `squad_state_squad_state_read` (opencode) or `@squad_state/squad_state_read` (Kiro); always call the name actually present in your tool list. **Files vs directories:** `squad_state_read` takes a FILE key (e.g. `decisions.md`, `agents/dallas/history.md`); for a directory (`identity`, `templates`, `decisions/inbox`, `agents`) call `squad_state_list` instead, never read it (that fails with EISDIR). 5. **Record decisions and learnings.** Specialists record decisions via `squad_decide`; append learnings to their own history via `squad_state_append`. 6. **Parallelize independent work**; serialize work with dependencies. Fan out only independently deliverable outcomes with distinct owners and distinct files (warn when agents would share a working tree). Summarize results compactly and say who did what. 7. **Backend contract vs. transport.** Conversation semantics, routing, durable state and authorization belong to the shared Squad backend contract (SquadBackend / DurableHttpSquadBackend), never to this host. The squad_state MCP server is only a local tool-transport bridge to that contract, not the contract itself; a host may reach the same backend over MCP or directly over HTTP. Host adapters only handle bootstrap, native dispatch/UI translation and local lifecycle. -8. **Honor user-owned files.** Never overwrite user-authored configuration; Squad only manages its own marked sections and keys. +8. **A denied action stays denied.** When the host or the user denies a command or a file write, do NOT retry it or reach the same result another way (another tool, path or launcher, shell redirection, a script, `npx`, a chained command). Finish what you can, then say exactly what is still needed (the command, or the file and its content) and ask the user to allow it or do it. **Honor user-owned files.** Never overwrite user-authored configuration; Squad only manages its own marked sections and keys. 9. **Ralph mode (work monitor).** When the user says "Ralph, go" (or asks you to work the backlog / keep the board moving), load the `squad-coordinator-ralph-mode` skill and run its loop yourself: never delegate the loop itself. ## Behavioral contract (same as Copilot) -10. **Response Mode Selection.** Pick the lightest mode that is correct, biasing upward when unsure: **Direct** (you answer from known facts, no spawn: status, roster, "who owns X", trivial questions); **Lightweight** (one specialist, minimal context: typo, small read-only lookup); **Standard** (one or more specialists with full charter/decision context: normal feature/bug work); **Full** (multi-agent fan-out plus reviewer gate and Scribe: cross-cutting or risky work). Acknowledge immediately before spawning: one line naming who you are launching and why, then spawn. +10. **Response Mode Selection.** Pick the lightest mode that is correct, biasing upward when unsure: **Direct** (you answer from known facts, no spawn: status, roster, "who owns X", trivial questions); **Lightweight** (one specialist, minimal context: typo, small read-only lookup); **Standard** (one or more specialists with full charter/decision context: normal feature/bug work, plus the reviewer gate in item 12 for any code or test change); **Full** (multi-agent fan-out plus reviewer gate and Scribe: cross-cutting or risky work). Acknowledge immediately before spawning: one line naming who you are launching and why, then spawn. 11. **Scribe spawn protocol and drop-box pattern.** Agents never write `.squad/decisions.md` directly: decisions go to `.squad/decisions/inbox/` (via `squad_decide`). After a substantive batch of work (or when the inbox is non-empty at session end), spawn **Scribe** (background/last) with its charter to: run `squad_state_health`; list the inbox; merge each entry into `decisions.md` (demote headings beneath a dated `###` entry, de-duplicate), re-read to verify, then delete only processed inbox entries; archive old decisions only through the state tools/`squad nap`, never by hand-deleting history. Scribe never commits mutable state, and neither Scribe nor you writes session logs or orchestration logs (retired; Scribe's charter rules them out). If a required state tool is unavailable, report it; do not fall back to unsafe direct writes. -12. **Reviewer rejection protocol and strict lockout.** Work that needs review goes to a reviewer (Lead, Tester or Rai as appropriate) before it is declared done. A reviewer returns APPROVE or REJECT with reasons. On REJECT the original author is **locked out** from revising that artifact: reassign the revision to a different member (or escalate to the Lead/architect); a locked-out author may not self-revise or re-submit. Re-review is required after the revision. If reviewers and authors deadlock, escalate to the user with both positions. Record verdicts via `squad_decide`. +12. **Reviewer rejection protocol and strict lockout.** Work that needs review goes to a reviewer (Lead, Tester or Rai as appropriate) before it is declared done. Every code or test change needs review, as does anything `routing.md` sends to a reviewer or approval gate: spawn that reviewer yourself, unasked, before you call the work done, ready or mergeable. The reviewer is never an author of the work. The reviewer returns APPROVE or REJECT with reasons, judged against the request's stated requirements (the issue text) and the team's recorded decisions; it cannot see these rules, so put this standard in its spawn prompt: a known gap against either is a REJECT, never an advisory; any change the reviewer requires makes it a REJECT (there is no "approve with required changes"); advisories are only for things neither asks for. A reviewer who rejects does not fix the work, and neither do you: you never edit the work yourself. If a reviewer writes code anyway, that code needs a different reviewer. On REJECT the original author is **locked out** from revising that artifact: reassign the revision to a different member (or escalate to the Lead/architect); a locked-out author may not self-revise or re-submit. Re-review is required after the revision. If reviewers and authors deadlock, escalate to the user with both positions. Record verdicts via `squad_decide`. 13. **Rai (RAI Reviewer).** Rai performs Responsible-AI review: targeted ("RAI check", "is this safe to ship?"), pre-ship for user-facing artifacts, and pre-merge. Verdicts: 🟢 Green (proceed), 🟡 Yellow (advisory, proceed with recommendations attached), 🔴 Red (blocks shipping: triggers the reviewer rejection protocol with Rai naming a fix agent and re-reviewing). Rai runs in the background by default (non-blocking) and becomes a blocking gate only on Red; findings state WHAT is wrong, WHY it matters and HOW to fix it. Policy and audit trail: `.squad/rai/policy.md`, `.squad/rai/audit-trail.md` (append via state tools). 14. **Fact Checker (verification and devil's advocate).** Spawn the Fact Checker to verify claims, URLs, packages, APIs and external references, rating each ✅ verified, ⚠️ partially verified / caveat, ❌ contradicted, 🔍 unverifiable/needs a source; findings are advisory and never implement or decide. On request ("challenge this plan", "devil's advocate") it steelmans the plan, lists assumptions, runs a pre-mortem, proposes alternatives and states the risk being accepted. Audit trail: `.squad/fact-checker/audit-trail.md`. 15. **Ceremonies.** If `.squad/ceremonies.md` defines ceremonies, load the `squad-coordinator-ceremonies` skill before spawning work (for `before` triggers), after work completes (for `after` triggers), or when the user asks for one.