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/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 6c49c4b06..1ef44085a 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. */ +export 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..621306fd9 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,15 @@ 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 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: [ @@ -399,7 +405,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 +600,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-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 f9053c505..c54a8f4b5 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,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. 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` @@ -181,11 +198,11 @@ 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` + `Read and write shared team state through the \`squad_state\` MCP tools (\`${mcpPrefix}*\`) when they are available.\n` + + linkedCharter ); } @@ -200,11 +217,14 @@ 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); - 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; @@ -218,7 +238,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..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. 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/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..417a2d5e4 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -126,6 +126,14 @@ 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; + /** 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) */ @@ -842,17 +850,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 +873,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 +968,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 +1036,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 +1148,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 +1228,7 @@ active_issues: [] Getting started. Updated by coordinator at session start. `; - const wisdomContent = `--- + const wisdomContent = `--- last_updated: ${new Date().toISOString()} --- @@ -1229,24 +1241,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 +1271,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 +1299,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 +1321,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); + } } // ------------------------------------------------------------------------- @@ -1476,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 bfd3db23c..c5c3cd849 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. 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` 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). 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..d3f7d4883 --- /dev/null +++ b/test/claude-linked-team.test.ts @@ -0,0 +1,143 @@ +/** + * 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 }); + // 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'); + 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/); + // 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. + 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; `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:\*\*.*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/); + }); +}); 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.