From aac2e3eca61368f923c341c84c5ce3b6050857a8 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 10:02:44 -0800 Subject: [PATCH] docs(claude): align Claude Code spec, docs and matrix with the code; add host projection regression guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of #2438, plus the Phase 7 ADR as Proposed. - Rename Claude Code's subagent tool from `Task` to `Agent` in the Claude dispatch section, harness-adapters §5/§7.2, the architecture doc and the host feature matrix; describe `model`, `run_in_background` and `isolation: "worktree"`. Describe coordinator-only dispatch as Squad's policy rather than a Claude Code limitation. - Correct harness-adapters §13: Claude's `.gitignore` lines sit under a `# squad:claude` comment, and `CLAUDE.md` uses the squad:begin/end block. - Restate the model-selection.claude cell: the host supports per-call and frontmatter models (runtime.perSpawnModel is now true); the cell stays unsupported until the digest gains the section (#2442). - Add a Claude Code section to client-compatibility.md, make the README badge and model line host-aware, and note that shared-digest gaps are not host-specific. - Add regression guards with a pinned squad_state launch spec: Copilot projection snapshot, no-diff steady-state upgrade, --client copilot touches only config.json, add/remove of Claude leaves Copilot files byte-identical, and upgrading an existing Claude install keeps user content and rolls back to the user's bytes. - Add ADR-003 (Proposed) on a host-neutral canonical coordinator. Closes #2440 Co-Authored-By: Claude Opus 5.5 --- .changeset/claude-agent-tool-dispatch.md | 5 + README.md | 4 +- ...-003-host-neutral-canonical-coordinator.md | 106 ++++++++++ .../client-harness-adapters-v0.2.md | 27 ++- .../docs/concepts/squad-2-0-architecture.md | 2 +- .../docs/reference/host-feature-matrix.md | 16 +- .../docs/scenarios/client-compatibility.md | 36 +++- .../src/client/adapters/coordinator.ts | 4 +- scripts/render-host-feature-matrix.mjs | 2 +- .../spec/suite-v0.2/host-feature-matrix.json | 14 +- ...-projection-regression-guards.test.ts.snap | 41 ++++ test/clients.test.ts | 2 +- .../host-projection-regression-guards.test.ts | 186 ++++++++++++++++++ 13 files changed, 415 insertions(+), 30 deletions(-) create mode 100644 .changeset/claude-agent-tool-dispatch.md create mode 100644 docs/specification/adr-003-host-neutral-canonical-coordinator.md create mode 100644 test/__snapshots__/host-projection-regression-guards.test.ts.snap create mode 100644 test/host-projection-regression-guards.test.ts diff --git a/.changeset/claude-agent-tool-dispatch.md b/.changeset/claude-agent-tool-dispatch.md new file mode 100644 index 000000000..ee460da10 --- /dev/null +++ b/.changeset/claude-agent-tool-dispatch.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": patch +--- + +The Claude Code coordinator now names Claude Code's `Agent` subagent tool (formerly `Task`) and describes its `run_in_background` and `isolation: "worktree"` options. Existing Claude Code installs see this wording change in `.claude/agents/squad.md` on the next `squad upgrade --client claude`; Copilot output is unchanged. diff --git a/README.md b/README.md index e40acc9a3..0b603a9f8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Human-led AI agent teams for any project.** One command. A team that helps you move faster with your code. -[![Platform](https://img.shields.io/badge/platform-GitHub%20Copilot-blue)](#what-is-squad) +[![Platform](https://img.shields.io/badge/platform-GitHub%20Copilot%20%2B%206%20more%20hosts-blue)](#use-squad-in-other-ai-hosts) Breaking changes are documented in [CHANGELOG.md](CHANGELOG.md). @@ -442,7 +442,7 @@ When agents finish, the coordinator records follow-up work and leaves a breadcru **And it's all in git.** Anyone who clones your repo gets the team — with all their accumulated knowledge. -**Each agent gets the right model.** Squad picks models quality-first by default: premium for architecture, research and review, standard for code and docs, fast for mechanical work. Opt in to the `balanced` or `economy` profile, or to the `hydrafusion` research preview with `"modelProfile": "hydrafusion"` in `.squad/config.json`. Per-agent selection works in the Copilot CLI and app; VS Code runs every agent on the one session model. See [Model Selection](https://github.github.com/squad/docs/features/model-selection/). +**Each agent gets the right model.** Squad picks models quality-first by default: premium for architecture, research and review, standard for code and docs, fast for mechanical work. Opt in to the `balanced` or `economy` profile, or to the `hydrafusion` research preview with `"modelProfile": "hydrafusion"` in `.squad/config.json`. Per-agent selection works in the Copilot CLI and app; VS Code runs every agent on the one session model, and so do the other hosts (Claude Code, Kiro, OpenCode, Cursor, Codex, Antigravity) for now — see the [Host Feature Matrix](docs/src/content/docs/reference/host-feature-matrix.md). See [Model Selection](https://github.github.com/squad/docs/features/model-selection/). --- diff --git a/docs/specification/adr-003-host-neutral-canonical-coordinator.md b/docs/specification/adr-003-host-neutral-canonical-coordinator.md new file mode 100644 index 000000000..309051691 --- /dev/null +++ b/docs/specification/adr-003-host-neutral-canonical-coordinator.md @@ -0,0 +1,106 @@ +# ADR 003: Host-Neutral Canonical Coordinator in the Workspace + +**Status:** Proposed + +**Tracking:** [#2438](https://github.com/github/squad/issues/2438) (Phase 7) + +**Affects:** `squad-client-harness-adapters/v0.2` §4–§5 (projection contract +and canonical paths) and §13 (upgrade and rollback) + +This ADR records a decision to be made. It does not change any code. Merging it +with status Proposed does not approve it. + +## Context + +### Where the coordinator lives today + +In this repository the coordinator's source is already host-neutral: +`.squad-templates/squad.agent.md`, copied by `scripts/sync-templates.mjs` into +`templates/`, `packages/squad-cli/templates/` and +`packages/squad-sdk/templates/`. + +In a **user workspace**, the only full copy is the Copilot one. +`squad init` writes `.github/agents/squad.agent.md` (about 80 KB), and +`CANONICAL_COORDINATOR_AGENT_PATH` in +`packages/squad-sdk/src/client/adapters/coordinator.ts` points at it. Every +other host gets a compact digest (under 24 KB, enforced by +`test/clients.test.ts`) that says: + +- `.github/agents/squad.agent.md` is the authoritative coordinator contract; +- when that file exists in the workspace, read it. + +So a Claude Code, Kiro, OpenCode, Cursor, Codex or Antigravity coordinator +depends on a file that lives under a Copilot-specific path and is shaped for +Copilot. A workspace that projects only non-Copilot hosts still gets +`.github/agents/squad.agent.md`, because `squad init` always writes it. + +### Why it matters now + +Epic #2438 makes Claude Code a first-class host. Phase 4 of that epic slims the +Claude coordinator by moving on-demand sections into Claude skills. How far +that can go depends on what the Claude coordinator is told is authoritative: +while the answer is "the Copilot file", the Claude coordinator may still read +80 KB of Copilot-shaped text on demand, which undercuts the slimming. + +## Options + +### A. Keep the canonical file at `.github/agents/squad.agent.md` (status quo) + +- No migration. Copilot surfaces keep discovering it where they do today. +- Non-Copilot hosts keep pointing at a Copilot path. Phase 4 slims only what + the Claude coordinator loads by default, not what it treats as authoritative. + +### B. Move the canonical file to a host-neutral workspace path and generate Copilot's from it + +For example `.squad/coordinator.md` (inside the team directory) or a path +under `.squad-templates/` in the workspace. + +- Every host, Copilot included, becomes a projection of one host-neutral + source, which matches how the other six hosts already work. +- `.github/agents/squad.agent.md` is still written for Copilot, generated from + the neutral source. Copilot discovery is unchanged. +- The digests point at the neutral path instead of the Copilot one. +- This is a projection-version change (harness-adapters §5) for every host, + and needs a migration in `squad upgrade`. + +### C. Keep the Copilot file canonical, but stop pointing non-Copilot hosts at it + +- Each host's digest becomes self-sufficient, with on-demand sections served + by host-native mechanisms (Claude skills, Kiro steering, and so on). +- No new file. The canonical contract remains defined only for Copilot, so + keeping the hosts' behavior in step relies on the digest parity tests alone. + +## Constraints on any choice + +- **Copilot output stays byte-identical.** If B is chosen, the generated + `.github/agents/squad.agent.md` must match today's bytes, checked by the + Copilot snapshot in `test/host-projection-regression-guards.test.ts`. +- **Copilot discovery is unaffected.** Copilot CLI, the Copilot app and VS Code + auto-discover `.github/agents/`; that file must keep being written wherever + Copilot is configured. +- **Upgrade and rollback stay byte-preserving** (harness-adapters §13): a user + who never opts in to a non-Copilot host sees no change. +- **The behavioral-contract parity tests keep passing** for every host. + +## Open questions + +1. Is moving the canonical file off the Copilot path acceptable for the Copilot + surfaces that auto-discover `.github/agents/`, given that the generated file + stays in place? +2. If B: should the neutral file live inside the team directory (`.squad/`), + where it can be served by `squad_state_read` and follows `teamRoot` in + remote mode, or outside it? +3. If B: is the Copilot file still written for workspaces where Copilot is not + in `clients[]`? + +## Decision + +Not yet made. Implementation, if approved, is a separate PR with its own +projection version bump and CHANGELOG entry. + +## Related artifacts + +- [Squad Client and Harness Adapter Profile v0.2](client-harness-adapters-v0.2.md) +- [ADR 001: Client, Control, and Execution Boundary](adr-001-client-control-execution-boundary.md) +- `packages/squad-sdk/src/client/adapters/coordinator.ts` +- `scripts/sync-templates.mjs` diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index a1090264e..4a8427179 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -289,7 +289,7 @@ distinct from negotiated `effectiveCapabilities`. | Host (`ClientId` / `hostKind`) | Agent anchor | MCP config | Support files | `submit` projection | |---|---|---|---|---| | GitHub Copilot (`copilot` / `github-copilot`) | `.github/agents/squad.agent.md` | `.mcp.json` | `.github/copilot-instructions.md` when present | `create_session`, `task`, or `runSubagent` depending on surface | -| Claude Code (`claude` / `claude-code`) | `.claude/agents/squad.md` | `.mcp.json` | `CLAUDE.md` marker block, `.gitignore` `# squad:claude` lines | Claude `Task` subagent tool | +| Claude Code (`claude` / `claude-code`) | `.claude/agents/squad.md` | `.mcp.json` | `CLAUDE.md` marker block, `.gitignore` `# squad:claude` lines | Claude `Agent` subagent tool | | Kiro (`kiro`) | `.kiro/steering/squad.md` | `.kiro/settings/mcp.json` | `.kiro/agents/squad.json`, Kiro spec and steering hooks | Kiro agent or spec execution hook | | OpenCode (`opencode`) | `.opencode/agent/squad.md` | `opencode.json` | host agent and MCP definition blocks in `opencode.json` | OpenCode agent execution entry declared in `opencode.json` | | Cursor (`cursor`) | `.cursor/agents/squad.md` | `.cursor/mcp.json` | `.cursor/rules/squad.mdc` | Cursor agent dispatch or inline agent run with projected rules | @@ -364,9 +364,19 @@ detail inside the harness adapter rather than a different workspace contract. ### 7.2 `submit` -`submit` maps to Claude Code's `Task` subagent tool. The harness adapter is -responsible for shaping the portable prompt, handling streaming callbacks, and -projecting Claude's task identity into the broker run identity. +`submit` maps to Claude Code's `Agent` subagent tool (named `Task` in earlier +Claude Code releases). Each call takes a `prompt` and a `subagent_type`, and +optionally: + +- `model`: a per-call model override; subagent definitions in + `.claude/agents/` can also set `model` and `effort` in their frontmatter; +- `run_in_background`: run the subagent without blocking the caller; +- `isolation: "worktree"`: run the subagent in its own git worktree under + `.claude/worktrees/` (which is why the projection ignores that directory). + +The harness adapter is responsible for shaping the portable prompt, handling +streaming callbacks, and projecting Claude's subagent identity into the broker +run identity. ### 7.3 `projectWorkspace` @@ -499,9 +509,12 @@ Companion-file ownership is explicit: - `.kiro/steering/squad.md`, `.kiro/agents/squad.json`, and `.kiro/settings/mcp.json` are fully Squad-managed once projected; -- `.claude/worktrees/` integration uses Squad-owned ID-scoped `.gitignore` - marker blocks with `` and - ``; +- `.claude/worktrees/` integration adds Squad-owned `.gitignore` lines under + an ID-scoped `# squad:claude` comment; rollback removes that comment and the + listed lines directly beneath it, and lines the user already had stay; +- the `CLAUDE.md` integration is a marker block between + `` and + ``; - `AGENTS.md` and `GEMINI.md` integrations are marker-scoped and therefore use byte-preserving rollback outside those managed regions. diff --git a/docs/src/content/docs/concepts/squad-2-0-architecture.md b/docs/src/content/docs/concepts/squad-2-0-architecture.md index 7d6b948ed..a72215728 100644 --- a/docs/src/content/docs/concepts/squad-2-0-architecture.md +++ b/docs/src/content/docs/concepts/squad-2-0-architecture.md @@ -49,7 +49,7 @@ The client plane lives in `@bradygaster/squad-sdk/client` (with `@bradygaster/sq | Host (`ClientId`) | Generated workspace projection | Coordinator dispatch primitive | |---|---|---| | GitHub Copilot (`copilot`) | `.github/agents/squad.agent.md`, `.mcp.json` | Copilot `task` / `create_session` / `runSubagent` | -| Claude Code (`claude`) | `.claude/agents/squad.md`, `CLAUDE.md` (managed marker block), `.mcp.json`, `.gitignore` block for `.claude/worktrees/` | Claude `Task` subagent tool | +| Claude Code (`claude`) | `.claude/agents/squad.md`, `CLAUDE.md` (managed marker block), `.mcp.json`, `.gitignore` block for `.claude/worktrees/` | Claude `Agent` subagent tool | | Kiro (`kiro`) | `.kiro/steering/squad.md` (`inclusion: manual`), `.kiro/agents/squad.json`, `.kiro/settings/mcp.json` | Kiro `squad` agent referencing `../steering/squad.md` | | OpenCode (`opencode`) | `.opencode/agent/squad.md`, `opencode.json` | OpenCode `@agent` / `task` primitive | | Cursor (`cursor`) | `.cursor/agents/squad.md`, `.cursor/rules/squad.mdc` (`alwaysApply: false`), `.cursor/mcp.json` | Cursor subagents or inline charter turn | diff --git a/docs/src/content/docs/reference/host-feature-matrix.md b/docs/src/content/docs/reference/host-feature-matrix.md index 7a9441ea0..c81ef9c47 100644 --- a/docs/src/content/docs/reference/host-feature-matrix.md +++ b/docs/src/content/docs/reference/host-feature-matrix.md @@ -54,7 +54,7 @@ Each matrix cell shows **capability validation** (for example `✅ 🧪` = suppo | Host | CLI | Coordinator file | MCP config | Subagents | Background | Per-spawn model | Subagents inherit MCP | |------|-----|------------------|------------|-----------|------------|-----------------|-----------------------| | **GitHub Copilot (CLI / app / VS Code)** (`copilot`) | `copilot` | .github/agents/squad.agent.md (canonical) | .mcp.json | yes | yes | yes | yes | -| **Claude Code** (`claude`) | `claude` | .claude/agents/squad.md (digest) + CLAUDE.md block | .mcp.json | yes | yes | no | yes | +| **Claude Code** (`claude`) | `claude` | .claude/agents/squad.md (digest) + CLAUDE.md block | .mcp.json | yes | yes | yes | yes | | **Kiro CLI** (`kiro`) | `kiro-cli` | .kiro/agents/squad.json + .kiro/steering/squad.md (manual) | .kiro/settings/mcp.json | yes | no | no | yes | | **OpenCode** (`opencode`) | `opencode` | .opencode/agents/squad.md (primary) | opencode.json (mcp.squad_state) | yes | yes | no | yes | | **Cursor** (`cursor`) | `cursor-agent` (installed; the live smoke run was blocked by a usage limit (see live run log), so cells without a dedicated test are untested) | .cursor/rules/squad.mdc (alwaysApply: false) + .cursor/agents/squad.md | .cursor/mcp.json | yes | no | no | yes | @@ -77,7 +77,7 @@ Capability counts and validation counts are independent breakdowns of the same c ## Expected failures (`it.fails`) -These digest gaps are pinned as expected failures, one test per host and gap: the `[]` test turns red the moment a stack adds the section for that host. Until then the cell is **unsupported** for that host (validated by the failing test), never degraded or supported. +These digest gaps are pinned as expected failures, one test per host and gap: the `[]` test turns red the moment a stack adds the section for that host. Until then the cell is **unsupported** for that host (validated by the failing test), never degraded or supported. A gap listed for every host lives in the shared coordinator digest, so it is not a host-specific gap: host-specific work (for example making Claude Code a first-class host, #2438) does not close it. | Feature | Test (title; each generated test appends the host as `[]`) | Hosts | Meaning | |---------|-----------------------------|-------|---------| @@ -252,7 +252,7 @@ Depends on: **b, d** · docs: [features/model-selection](/squad/docs/features/mo | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ❌ unsupported | 🧪 validated-test (expected failure) | Canonical §Model Selection + `task` tool `model`/`reasoning_effort`/`context_tier` params; `.squad/config.json` overrides. _Expected-fail digest gap (per-host it.fails in test/clients.test.ts): The CLI/SDK side works, but the projected digest carries no section for it, so the coordinator is not told about it on this host. Unsupported on this host until the section ships._ | `validated-test:test/model-selector-policy.test.ts::*`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | -| `claude` | ❌ unsupported | 🧪 validated-test (expected failure) | `Task` has no per-call model; only subagent-definition `model:` frontmatter. Digest lacks the model-selection section. _Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | +| `claude` | ❌ unsupported | 🧪 validated-test (expected failure) | Host can: the `Agent` tool takes a per-call `model`, and subagent definitions take `model` and `effort` frontmatter. Unsupported because the digest lacks the model-selection section, so the coordinator never uses them; per-member `model`/`effort` frontmatter is tracked in #2442. _Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | | `kiro` | ❌ unsupported | 🧪 validated-test (expected failure) | Digest has no model-selection section (only `model-selection-reference.md` on demand); host dispatch primitive has no per-spawn model parameter, so the session model is inherited. _Record as a host limitation, not a bug: `squad.agent.md` already says not to rely on per-spawn model control cross-platform. 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/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | | `opencode` | ❌ unsupported | 🧪 validated-test (expected failure) | Model is set per agent in `opencode.json` (`agent.squad.model`), not per `task` call; reasoning/context tiers unsupported. _Expected-fail digest gap (per-host it.fails in test/clients.test.ts): unsupported on this host until the section ships._ | `validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | | `cursor` | ❌ unsupported | 🧪 validated-test (expected failure) | Digest has no model-selection section (only `model-selection-reference.md` on demand); host dispatch primitive has no per-spawn model parameter, so the session model is inherited. _Record as a host limitation, not a bug: `squad.agent.md` already says not to rely on per-spawn model control cross-platform. 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/clients.test.ts::coordinator digest vs. host feature matrix`
`static-only` | @@ -1004,11 +1004,11 @@ Depends on: **d** · docs: [concepts/squad-2-0-architecture](/squad/docs/concept | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 📄 static-only | `task` subagents inherit the session MCP servers. | `static-only` | -| `claude` | ✅ supported | 🖥️ validated-live | Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live: Galois/Fermat/Scribe each called `squad_state_*` tools from inside their spawn (history appended, inbox drained)._ | `validated-manual:lab:parallel-fanout@2026-10-22` | -| `kiro` | ✅ supported | ⛔ blocked — live run blocked on the reference machine: kiro-cli chat failed: "The monthly usage limit has been reached". | Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `static-only` | -| `opencode` | ✅ supported | 🖥️ validated-live | Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live: Galois/Fermat/Scribe each called `squad_state_*` tools from inside their spawn (history appended, inbox drained)._ | `validated-manual:lab:parallel-fanout@2026-10-22` | -| `cursor` | ✅ supported | ⛔ blocked — live run blocked on the reference machine: cursor-agent -p failed: "You've hit your usage limit". | Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `static-only` | -| `codex` | ✅ supported | ❔ untested | Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _No Codex binary or ChatGPT/API auth on the reference machine; static-only._ | `static-only` | +| `claude` | ✅ supported | 🖥️ validated-live | Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live: Galois/Fermat/Scribe each called `squad_state_*` tools from inside their spawn (history appended, inbox drained)._ | `validated-manual:lab:parallel-fanout@2026-10-22` | +| `kiro` | ✅ supported | ⛔ blocked — live run blocked on the reference machine: kiro-cli chat failed: "The monthly usage limit has been reached". | Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `static-only` | +| `opencode` | ✅ supported | 🖥️ validated-live | Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live: Galois/Fermat/Scribe each called `squad_state_*` tools from inside their spawn (history appended, inbox drained)._ | `validated-manual:lab:parallel-fanout@2026-10-22` | +| `cursor` | ✅ supported | ⛔ blocked — live run blocked on the reference machine: cursor-agent -p failed: "You've hit your usage limit". | Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `static-only` | +| `codex` | ✅ supported | ❔ untested | Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents). _No Codex binary or ChatGPT/API auth on the reference machine; static-only._ | `static-only` | | `antigravity` | ⚠️ degraded | ⛔ blocked — live run blocked on the reference machine: agy -p failed: RESOURCE_EXHAUSTED 429 "Individual quota reached" (resets in ~75h). | `invoke_subagent` starts with a clean context; MCP attached via agent frontmatter may not propagate — specialists return results for the coordinator to persist. _Live run blocked on 2026-10-22: antigravity quota/usage limit reached on the reference machine._ | `static-only` | ## Live run log diff --git a/docs/src/content/docs/scenarios/client-compatibility.md b/docs/src/content/docs/scenarios/client-compatibility.md index 91cc9264b..d75c2e182 100644 --- a/docs/src/content/docs/scenarios/client-compatibility.md +++ b/docs/src/content/docs/scenarios/client-compatibility.md @@ -4,7 +4,7 @@ > **New in Squad 2.0:** `squad init --client copilot|claude|kiro|opencode|cursor|codex|antigravity|all` and `squad upgrade --client ...` now project host-native adapters across all 7 supported hosts (GitHub Copilot, Claude Code, Kiro, OpenCode, Cursor, OpenAI Codex, and Google Antigravity) while keeping the backend contract portable. Start with [Using Squad across clients](./using-squad-across-clients.md) for the hands-on walkthrough and [Squad 2.0 architecture](../concepts/squad-2-0-architecture.md) for the three-plane model, MCP-vs-HTTP transport story, and the `G1`-`G9` conformance gates. -Squad runs on multiple Copilot surfaces — each with its own agent spawning mechanism, tool set, and constraints. This document maps Squad's core capabilities across CLI, VS Code, JetBrains, and GitHub.com to help you understand what works where. +Squad runs on multiple Copilot surfaces — each with its own agent spawning mechanism, tool set, and constraints. This document maps Squad's core capabilities across CLI, VS Code, JetBrains, and GitHub.com to help you understand what works where. The Quick Reference table covers Copilot surfaces only; [Claude Code](#claude-code) has its own section below, and the [Host Feature Matrix](../reference/host-feature-matrix.md) covers every supported host feature by feature. ## Quick Reference @@ -186,6 +186,40 @@ Squad runs on VS Code with **conditional support**. Key differences from CLI: --- +## Claude Code + +**Status:** Supported through `squad init --client claude` (or `squad upgrade --client claude`), with live lab runs. Per-feature status is in the [Host Feature Matrix](../reference/host-feature-matrix.md); work to make Claude Code a first-class host is tracked in [#2438](https://github.com/github/squad/issues/2438). + +### What Squad projects + +- `.claude/agents/squad.md` — the coordinator, as a Claude Code subagent named `squad` +- `.mcp.json` — the `squad_state` MCP server, merged without touching your other servers +- `CLAUDE.md` — a Squad-managed marker block +- `.gitignore` — `.claude/worktrees/` under a `# squad:claude` comment + +### Starting a session + +```bash +claude --agent squad --mcp-config .mcp.json +``` + +Interactive Claude Code doesn't load project-scoped `.mcp.json` servers until you approve them, so pass the config explicitly. Headless runs (`claude -p`) need `--mcp-config` too, or a prior approval for the project; without it, the coordinator stops at the state-backend handshake. + +### Capabilities + +| Feature | Status | Notes | +|---------|--------|-------| +| **Sub-agent spawning** | ✅ `Agent` tool | Each member runs as a general-purpose subagent with its charter inlined. Squad dispatches specialists only from the coordinator; members don't spawn subagents of their own. This is Squad's dispatch policy and doesn't depend on whether your Claude Code version allows nested subagents. | +| **Parallel fan-out** | ✅ | Independent `Agent` calls in one message run in parallel. | +| **Background/async execution** | ✅ `run_in_background` | Validated in live runs. | +| **Worktree isolation** | ✅ `isolation: "worktree"` | Claude Code creates worktrees under `.claude/worktrees/`, which Squad ignores in git. | +| **Per-spawn model selection** | ❌ Not used yet | Claude Code supports it (a per-call `model`, plus `model` and `effort` in subagent frontmatter), but the Squad coordinator doesn't use it yet. Tracked in [#2442](https://github.com/github/squad/issues/2442). | +| **MCP server access** | ✅ | `squad_state` tools appear as `mcp__squad_state__*`; subagents inherit them. | +| **Hooks** | ❌ Not projected yet | Tracked in [#2442](https://github.com/github/squad/issues/2442). | +| **`squad watch --execute`** | ⚠️ Limited | Runs through the Copilot CLI unless you pass `--agent-cmd` with a `claude -p …` command. A built-in preset is tracked in [#2442](https://github.com/github/squad/issues/2442). | + +--- + ## Platform Adaptation Guide ### For Developers Using Squad diff --git a/packages/squad-sdk/src/client/adapters/coordinator.ts b/packages/squad-sdk/src/client/adapters/coordinator.ts index d5df81b9d..48281de73 100644 --- a/packages/squad-sdk/src/client/adapters/coordinator.ts +++ b/packages/squad-sdk/src/client/adapters/coordinator.ts @@ -153,10 +153,10 @@ const STRATEGIES: Record = { Spawn each specialist with the \`task\` tool (Copilot CLI), \`create_session\` (Copilot app) or \`runSubagent\` (VS Code). Pass the full specialist prompt from the core protocol. Run independent specialists in parallel by issuing multiple spawn calls in the same turn. The \`squad_state\` MCP server is loaded from \`.mcp.json\`.`, }, claude: { - summary: 'Claude Code Task subagent', + summary: 'Claude Code Agent subagent', section: `## Dispatch adapter: Claude Code -Spawn each specialist with the \`Task\` tool, using a general-purpose subagent with the member's charter (\`.squad/agents//charter.md\`) inlined in the prompt. Launch independent \`Task\` calls in one message to run them in parallel. Subagents cannot spawn subagents, so only the coordinator dispatches. The \`squad_state\` MCP tools are available as \`mcp__squad_state__*\` from \`.mcp.json\`.`, +Spawn each specialist with the \`Agent\` tool, using a general-purpose subagent with the member's charter (\`.squad/agents//charter.md\`) inlined in the prompt. Launch independent \`Agent\` calls in one message to run them in parallel; \`run_in_background: true\` lets long or non-blocking work (Scribe, Rai) run while you continue, and \`isolation: "worktree"\` gives an implementer its own git worktree under \`.claude/worktrees/\`. Squad dispatches specialists only from the coordinator: members never spawn subagents of their own. The \`squad_state\` MCP tools are available as \`mcp__squad_state__*\` from \`.mcp.json\`.`, }, kiro: { summary: 'Kiro subagent + steering, MCP backend', diff --git a/scripts/render-host-feature-matrix.mjs b/scripts/render-host-feature-matrix.mjs index bdeba830e..31fddc0a5 100644 --- a/scripts/render-host-feature-matrix.mjs +++ b/scripts/render-host-feature-matrix.mjs @@ -117,7 +117,7 @@ export function render(matrix) { if (Array.isArray(matrix.expectedFailures) && matrix.expectedFailures.length > 0) { lines.push('## Expected failures (`it.fails`)'); lines.push(''); - lines.push('These digest gaps are pinned as expected failures, one test per host and gap: the `[]` test turns red the moment a stack adds the section for that host. Until then the cell is **unsupported** for that host (validated by the failing test), never degraded or supported.'); + lines.push('These digest gaps are pinned as expected failures, one test per host and gap: the `[]` test turns red the moment a stack adds the section for that host. Until then the cell is **unsupported** for that host (validated by the failing test), never degraded or supported. A gap listed for every host lives in the shared coordinator digest, so it is not a host-specific gap: host-specific work (for example making Claude Code a first-class host, #2438) does not close it.'); lines.push(''); lines.push('| Feature | Test (title; each generated test appends the host as `[]`) | Hosts | Meaning |'); lines.push('|---------|-----------------------------|-------|---------|'); 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 d198a0462..aab13212f 100644 --- a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json +++ b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json @@ -51,7 +51,7 @@ "runtime": { "subagents": true, "backgroundRuns": true, - "perSpawnModel": false, + "perSpawnModel": true, "sqlTool": false, "shell": true, "mcpInheritedBySubagents": true @@ -441,7 +441,7 @@ "capability": "unsupported", "validation": "validated-test", "expectedFailure": true, - "mechanism": "`Task` has no per-call model; only subagent-definition `model:` frontmatter. Digest lacks the model-selection section.", + "mechanism": "Host can: the `Agent` tool takes a per-call `model`, and subagent definitions take `model` and `effort` frontmatter. Unsupported because the digest lacks the model-selection section, so the coordinator never uses them; per-member `model`/`effort` frontmatter is tracked in #2442.", "evidence": [ "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix", "static-only" @@ -4546,7 +4546,7 @@ "claude": { "capability": "supported", "validation": "validated-live", - "mechanism": "Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", + "mechanism": "Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", "evidence": [ "validated-manual:lab:parallel-fanout@2026-10-22" ], @@ -4556,7 +4556,7 @@ "capability": "supported", "validation": "blocked", "blockedReason": "live run blocked on the reference machine: kiro-cli chat failed: \"The monthly usage limit has been reached\".", - "mechanism": "Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", + "mechanism": "Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", "evidence": [ "static-only" ], @@ -4565,7 +4565,7 @@ "opencode": { "capability": "supported", "validation": "validated-live", - "mechanism": "Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", + "mechanism": "Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", "evidence": [ "validated-manual:lab:parallel-fanout@2026-10-22" ], @@ -4575,7 +4575,7 @@ "capability": "supported", "validation": "blocked", "blockedReason": "live run blocked on the reference machine: cursor-agent -p failed: \"You've hit your usage limit\".", - "mechanism": "Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", + "mechanism": "Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", "evidence": [ "static-only" ], @@ -4584,7 +4584,7 @@ "codex": { "capability": "supported", "validation": "untested", - "mechanism": "Subagents inherit MCP servers (Claude `Task`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", + "mechanism": "Subagents inherit MCP servers (Claude `Agent`, Codex `spawn_agent`, OpenCode child sessions, Kiro subagents).", "evidence": [ "static-only" ], diff --git a/test/__snapshots__/host-projection-regression-guards.test.ts.snap b/test/__snapshots__/host-projection-regression-guards.test.ts.snap new file mode 100644 index 000000000..61bab6d33 --- /dev/null +++ b/test/__snapshots__/host-projection-regression-guards.test.ts.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`host projection regression guards (#2438) > guard 1: the Copilot projection is byte-identical to the snapshot 1`] = ` +{ + ".copilot/mcp-config.json": "c68ab872f9f9b0a4ff12b221ee8bccb6dfec1bf1b886f05971935d5bc48b33e6", + ".gitattributes": "fe8886d03bd8d8c7fb0b274cb8748fe4c97b08ce8b55c2aa72a2e173a0235ce3", + ".github/agents/squad.agent.md": "b5160cf1f21a717653cfd0c068f7b405fe8fb40c86c47882b105a0e383cae7fa", + ".github/skills/agent-collaboration/SKILL.md": "e1943766f6aa5efeb145f22d1583f5b38d4d8a7109ae17f085ec064dada4b9bf", + ".github/skills/coordinator-init-mode/SKILL.md": "3e74448a48372968a7d14c43e7461bed1aee968073461341a6e33d57de0f85e1", + ".github/skills/coordinator-response-mode/SKILL.md": "0be5f6ac5e3ad7c8b818770a72fa8bf3693196c23c9bba1ae7675caabd73e8c1", + ".github/skills/coordinator-source-of-truth/SKILL.md": "d541d499104627392f45319bfdd85d8d77693d4e7c1bed0b616e985bd33fd0b9", + ".github/skills/cross-squad-communication/SKILL.md": "c8d7744cd194cf070ba53537586b6a4f19dfe09024b6cc18ffd85bd65f5aed70", + ".github/skills/cross-squad/SKILL.md": "8f62616ee39835c8e2954cb9327cf813cf1c6d5f6d8652773c82f042aa076270", + ".github/skills/error-recovery/SKILL.md": "a6bc62ea076cfbbd61b8348fc269014a87400c7b69c8d7cb14c1469f0ce87977", + ".github/skills/git-workflow/SKILL.md": "b85373cb599d07bd13bf52939e9167df1cf5b1d16cb23d8913148afac7d6f50b", + ".github/skills/iterative-retrieval/SKILL.md": "a1ac94cbaed7ddbf49cc452f4045521d33a0303458c63c356bd523941ee4f22c", + ".github/skills/reflect/SKILL.md": "56a68807c843c9d4c1ab837ce8c91c5469c595443b626c34aabaf820732aeaba", + ".github/skills/reviewer-protocol/SKILL.md": "c262486a165fc9f5749c4ab96042dc6e549b43ad8a2d40a5b2ba05a79b4ea8fc", + ".github/skills/secret-handling/SKILL.md": "b59ecbc69e0e5d295f0dcf6a90cd555e86c244eaacef786a753fca0e78ea8765", + ".github/skills/session-recovery/SKILL.md": "54a7c1feba619de57cc212774406331fd53447d48d565430489a6202a0e6b67a", + ".github/skills/squad-conventions/SKILL.md": "7e1a53f10ceed067e38de48865bfc212deb1fdb04fee65038a7a135753e25c7c", + ".github/skills/squad-help/SKILL.md": "d2696395847e146dffc88668890adbd4104b3a9fe72be3fb47d773511eb9a4f8", + ".github/skills/squad-version-check/SKILL.md": "f75495722a6bfc3a5008b03c1c18228f01966e48fc24ee07f3f2521f2bdf52fd", + ".github/skills/squad/SKILL.md": "5e4c08f0c9b297b194baf5ad73cafae644fead47a929ef4286cd0c3c58b5c142", + ".github/skills/test-discipline/SKILL.md": "d84983f8e88130a670424796b38c4f947af9f05bb781c6b6e5bfdb81484345bc", + ".github/skills/tiered-memory/SKILL.md": "1ed0bcef7dbcf6fcd8f41dea1a510080cccca866efed45bad6e59e62c6ededec", + ".github/workflows/squad-ci.yml": "3ed813a21551605ac4342f01f921a2ad9d381f4195a3fab4027a54519944e742", + ".github/workflows/squad-docs.yml": "e15ff624e8bee0e83948e582f4d60b0f05780aa9adb9cee2846fcaf53224d6ed", + ".github/workflows/squad-heartbeat.yml": "e69f94f1f8dcbc0bb2205bb872af906073ea679e4013e18aca7b9cd790a6d266", + ".github/workflows/squad-insider-release.yml": "2369bb2e6d0c920676d901ecf14a1fa7c58cae7d1d4cde34d481496b1278c67c", + ".github/workflows/squad-issue-assign.yml": "5b2163342050ba55cbb574698b0d471e8fa57e4ad6c06a5fff0f104b1304b90a", + ".github/workflows/squad-label-enforce.yml": "3c26819a5ee082b46dd13ae09ebb9390e96a72e7023ad434cf1eb1eb4df631e9", + ".github/workflows/squad-preview.yml": "18eef9d2cbd11e4a7946771df09c8ecedce290d889edada97ce6131c422a7456", + ".github/workflows/squad-promote.yml": "56438c867b9f24046f07ad4b1b007e83b5842d6fae272750e36694fc3d5166c2", + ".github/workflows/squad-release.yml": "660097b7d92e640037d3d18dc6044a467774719fa3a2168fa459b6b66e91e873", + ".github/workflows/squad-triage.yml": "54f8a35713f9cd88bef9ee54b03da8f7d9cbd0bbdcbb03c73cb0dd37fd9bd1f2", + ".github/workflows/sync-squad-labels.yml": "105f9f0b1e004833154c6511c84b7f70c465e69e1e8f3da8676ec1255396c34d", + ".gitignore": "37aa6c4e49c873d2603b79e173ba46e216b18c596057964aac1cd6e62ff4e89f", + ".mcp.json": "bfdf4acbf0b686e8cf8450c5416b3983ceda7016718e6b1e5a551d2576d1eb80", +} +`; diff --git a/test/clients.test.ts b/test/clients.test.ts index 4dabf4d3f..eadc8c52c 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -100,7 +100,7 @@ describe('coordinator instructions', () => { }); it('mentions the host-specific spawn primitive', () => { expect(buildCoordinatorInstructions('copilot')).toMatch(/runSubagent/); - expect(buildCoordinatorInstructions('claude')).toMatch(/`Task`/); + expect(buildCoordinatorInstructions('claude')).toMatch(/`Agent`/); expect(buildCoordinatorInstructions('opencode')).toMatch(/`general` subagent/); }); }); diff --git a/test/host-projection-regression-guards.test.ts b/test/host-projection-regression-guards.test.ts new file mode 100644 index 000000000..c2e5e5f49 --- /dev/null +++ b/test/host-projection-regression-guards.test.ts @@ -0,0 +1,186 @@ +/** + * Regression guards for host-specific work (epic #2438, Claude Code as a first-class host). + * + * Every PR under that epic must keep these green: + * 1. The Copilot projection written by `squad init` + `squad upgrade` is byte-identical to the + * snapshot (only the package version, which changes on every build, and CRLF line endings + * are normalized). + * 2. `squad upgrade` on a steady-state repo without `claude` in `clients[]` changes no file. + * 3. `squad upgrade --client copilot` changes only `.squad/config.json` and writes no Claude files. + * 4. Adding Claude and then removing it (`uninstallClients`) leaves the Copilot projection untouched. + * 5. `squad upgrade --client claude` on an existing Claude install with user content in the shared + * files changes only Squad-managed content, and removing Claude restores the user's bytes. + * + * The squad_state MCP launch spec is pinned to the exact-version `npx` form for this suite: the real + * resolver writes machine-specific paths (node binary, checkout location) once the CLI is built, which + * would make the snapshot depend on where and whether the checkout was built. + * + * Byte-exact rollback of pre-existing user files for every host through `installClients` is also + * covered in test/clients.test.ts ("Reviewer gate ... byte-exact rollback across all 7 hosts"). + * + * If guard 1 fails in a PR that intentionally changes Copilot output (outside #2438), review the + * listed files and update the snapshot with `npx vitest run test/host-projection-regression-guards.test.ts -u`. + * PRs under #2438 must not change Copilot output. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runInit } from '../packages/squad-cli/src/cli/core/init.js'; +import { runUpgrade } from '../packages/squad-cli/src/cli/core/upgrade.js'; +import { uninstallClients } from '../packages/squad-sdk/src/client/adapters/index.js'; + +// Deterministic launch spec: the pinned exact-version fallback, never a local binary or checkout path. +vi.mock('../packages/squad-cli/src/cli/core/mcp-spec.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSquadStateMcpSpec: (cliVersion: string) => + actual.resolveSquadStateMcpSpec(cliVersion, { standaloneCheck: () => null, localEntryCheck: () => null }), + }; +}); + +const CLI_VERSION: string = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../packages/squad-cli/package.json'), 'utf8'), +).version; + +/** Claude Code paths that must never appear unless `claude` is opted in. */ +const CLAUDE_PATHS = ['.claude', 'CLAUDE.md']; + +let root: string; +let restoreEnv: () => void; + +/** sha256 of every file under `root` (except `.git/`), keyed by POSIX-style relative path. */ +function hashTree(opts: { excludeSquadDir?: boolean; normalizeVersion?: boolean } = {}): Record { + const out: Record = {}; + const walk = (dir: string) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, e.name); + const rel = path.relative(root, abs).split(path.sep).join('/'); + if (e.isDirectory()) { + if (e.name === '.git') continue; + if (opts.excludeSquadDir && rel === '.squad') continue; + walk(abs); + continue; + } + let bytes = fs.readFileSync(abs); + // Normalize the build-stamped version and line endings (Windows checkouts may carry CRLF templates). + if (opts.normalizeVersion) { + bytes = Buffer.from(bytes.toString('utf8').split(CLI_VERSION).join('').replace(/\r\n/g, '\n'), 'utf8'); + } + out[rel] = crypto.createHash('sha256').update(bytes).digest('hex'); + } + }; + walk(root); + return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))); +} + +function changedPaths(before: Record, after: Record): string[] { + return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((k) => before[k] !== after[k]).sort(); +} + +/** `squad init` followed by one `squad upgrade`: the state an existing Copilot-only user is in. */ +async function steadyStateCopilotRepo(): Promise { + await runInit(root, { includeWorkflows: false, includeVscodeDefault: false }); + await runUpgrade(root, {}); +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-host-guards-')); + const prev = { + SQUAD_NONINTERACTIVE: process.env['SQUAD_NONINTERACTIVE'], + NO_COLOR: process.env['NO_COLOR'], + SQUAD_STANDALONE_HOME: process.env['SQUAD_STANDALONE_HOME'], + }; + process.env['SQUAD_NONINTERACTIVE'] = '1'; + process.env['NO_COLOR'] = '1'; + delete process.env['SQUAD_STANDALONE_HOME']; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + restoreEnv = () => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }; +}); + +afterEach(() => { + restoreEnv(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('host projection regression guards (#2438)', () => { + it('guard 1: the Copilot projection is byte-identical to the snapshot', async () => { + await steadyStateCopilotRepo(); + const projection = hashTree({ excludeSquadDir: true, normalizeVersion: true }); + expect(Object.keys(projection)).toContain('.github/agents/squad.agent.md'); + for (const p of CLAUDE_PATHS) expect(fs.existsSync(path.join(root, p)), p).toBe(false); + expect(projection).toMatchSnapshot(); + }, 60_000); + + it('guard 2: squad upgrade on a steady-state repo without claude changes no file', async () => { + await steadyStateCopilotRepo(); + const before = hashTree(); + await runUpgrade(root, {}); + expect(changedPaths(before, hashTree()), 'squad upgrade').toEqual([]); + await runUpgrade(root, { force: true }); + expect(changedPaths(before, hashTree()), 'squad upgrade --force').toEqual([]); + }, 60_000); + + it('guard 3: squad upgrade --client copilot changes only .squad/config.json', async () => { + await steadyStateCopilotRepo(); + const before = hashTree(); + await runUpgrade(root, { clients: ['copilot'] }); + expect(changedPaths(before, hashTree())).toEqual(['.squad/config.json']); + for (const p of CLAUDE_PATHS) expect(fs.existsSync(path.join(root, p)), p).toBe(false); + }, 60_000); + + it('guard 4: adding and then removing Claude leaves the Copilot projection untouched', async () => { + await steadyStateCopilotRepo(); + const before = hashTree({ excludeSquadDir: true }); + await runUpgrade(root, { clients: ['copilot', 'claude'] }); + expect(fs.existsSync(path.join(root, '.claude/agents/squad.md'))).toBe(true); + // Claude may only add its own files and merge into the shared ones; Copilot-only files stay byte-identical. + const shared = new Set(['.mcp.json', '.gitignore']); + const withClaude = hashTree({ excludeSquadDir: true }); + for (const p of changedPaths(before, withClaude)) { + const ownedByClaude = CLAUDE_PATHS.some((c) => p === c || p.startsWith(`${c}/`)); + expect(ownedByClaude || shared.has(p), `claude install touched ${p}`).toBe(true); + } + uninstallClients(['claude'], { root, mcp: { command: 'squad', args: ['state-mcp'] } }, ['copilot']); + expect(changedPaths(before, hashTree({ excludeSquadDir: true })), 'after uninstalling claude').toEqual([]); + }, 60_000); + + it('guard 5: upgrading an existing Claude install keeps user content, and removing Claude restores it', async () => { + await steadyStateCopilotRepo(); + // User-owned content in every file Claude shares with the user or with Copilot. + const mcp = JSON.parse(fs.readFileSync(path.join(root, '.mcp.json'), 'utf8')); + mcp.mcpServers.user_srv = { command: 'user-mcp', args: ['--flag'] }; + fs.writeFileSync(path.join(root, '.mcp.json'), `${JSON.stringify(mcp, null, 2)}\n`); + fs.appendFileSync(path.join(root, '.gitignore'), 'user-build/\n'); + fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# User Claude notes\n\nKeep this paragraph.\n'); + const userBytes = (p: string) => fs.readFileSync(path.join(root, p), 'utf8'); + const original = Object.fromEntries(['.mcp.json', '.gitignore', 'CLAUDE.md'].map((p) => [p, userBytes(p)])); + const before = hashTree({ excludeSquadDir: true }); + + await runUpgrade(root, { clients: ['copilot', 'claude'] }); + const installed = hashTree({ excludeSquadDir: true }); + expect(JSON.parse(userBytes('.mcp.json')).mcpServers.user_srv).toEqual({ command: 'user-mcp', args: ['--flag'] }); + expect(userBytes('.gitignore')).toContain('user-build/'); + expect(userBytes('CLAUDE.md')).toContain('Keep this paragraph.'); + + // Upgrading the existing Claude install is a no-op for every file once it is current. + await runUpgrade(root, { clients: ['copilot', 'claude'] }); + expect(changedPaths(installed, hashTree({ excludeSquadDir: true })), 'squad upgrade --client claude').toEqual([]); + await runUpgrade(root, { clients: ['copilot', 'claude'], force: true }); + expect(changedPaths(installed, hashTree({ excludeSquadDir: true })), 'squad upgrade --client claude --force').toEqual([]); + + uninstallClients(['claude'], { root, mcp: { command: 'squad', args: ['state-mcp'] } }, ['copilot']); + for (const [p, bytes] of Object.entries(original)) expect(userBytes(p), `rollback of ${p}`).toBe(bytes); + expect(changedPaths(before, hashTree({ excludeSquadDir: true })), 'after uninstalling claude').toEqual([]); + }, 60_000); +});