diff --git a/.changeset/claude-governance-hooks.md b/.changeset/claude-governance-hooks.md new file mode 100644 index 000000000..964dd3f41 --- /dev/null +++ b/.changeset/claude-governance-hooks.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Claude Code: opt-in advisory governance hooks. With `"claudeHooks": "advisory"` in `.squad/config.json`, `squad upgrade --client claude` adds Squad hooks to `.claude/settings.json`, keeping your other settings and hooks. The hooks give the coordinator session context, warn a locked-out member who edits work under review, add a handoff summary after each member finishes, and remind the coordinator to run Scribe. They never block a tool call. New `squad lockout` command records reviewer lockouts in `.squad/governance/lockouts.json`, and the new `squad hook claude ` command runs the hooks. diff --git a/.changeset/claude-member-subagents.md b/.changeset/claude-member-subagents.md new file mode 100644 index 000000000..432a3603a --- /dev/null +++ b/.changeset/claude-member-subagents.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Claude Code: each team member, built-ins included, is projected as its own subagent in `.claude/agents/.md`, generated from its charter. Model, reasoning effort and the new `X-squad-host-claude` charter extension (`Tools`, `Isolation`) map onto the subagent frontmatter, and the Claude coordinator now routes to named members and applies per-spawn model selection. Squad never overwrites a user-authored file at a member path (the result is reported as `skipped`), removes the file of a member whose charter is gone, and removes every generated member file on rollback. `squad cast` refreshes the member files when `claude` is configured. Projections for other hosts are unchanged. diff --git a/.changeset/claude-projection-v2.md b/.changeset/claude-projection-v2.md new file mode 100644 index 000000000..28208ced4 --- /dev/null +++ b/.changeset/claude-projection-v2.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": minor +--- + +The Claude Code projection is now v2 (`CLAUDE_PROJECTION_VERSION`): member subagents, skills and the opt-in `settings.json` hooks ship as one version, and every generated Claude Markdown file states it in its generated-file note. v1 files are still recognised as Squad-owned and upgraded in place. Rollback of the `CLAUDE.md`, `AGENTS.md` and `GEMINI.md` marker blocks now restores the original bytes exactly, including files that ended without a newline or with a blank line. diff --git a/.changeset/claude-skills-projection.md b/.changeset/claude-skills-projection.md new file mode 100644 index 000000000..733a24be4 --- /dev/null +++ b/.changeset/claude-skills-projection.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": minor +--- + +Claude Code: Squad's template skills are projected as native skills in `.claude/skills/`, except where a team skill of the same name exists in `.squad/skills/`. The Claude coordinator now loads Init Mode, Ralph mode, ceremonies, GitHub Issues mode and PRD mode on demand from generated `squad-coordinator-*` skills instead of carrying them in its always-loaded prompt. The digest for other hosts and `.github/agents/squad.agent.md` are unchanged. diff --git a/.changeset/claude-watch-preset.md b/.changeset/claude-watch-preset.md new file mode 100644 index 000000000..95399f256 --- /dev/null +++ b/.changeset/claude-watch-preset.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": minor +--- + +`squad watch --execute` gains a built-in Claude Code runner. `--agent-preset claude` (or `watch.agentPreset: "claude"`) runs each round as `claude -p --agent squad --output-format stream-json --verbose` with a private `squad_state`-only MCP config (never the repository's `.mcp.json`), preflights the projected coordinator and the `claude` CLI, and treats an error `result` event as a failed round, including monitor, retro and decision-hygiene runs. Execute mode defaults to `--permission-mode bypassPermissions`; `--claude-flags` overrides it. `watch.claudeFlags` in `.squad/config.json` is limited to model, permission-mode, effort and turn-limit flags. The default Copilot runner is unchanged. diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 4a8427179..14e8583f4 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 `Agent` subagent tool | +| Claude Code (`claude` / `claude-code`) | `.claude/agents/squad.md` | `.mcp.json` | `CLAUDE.md` marker block, `.gitignore` `# squad:claude` lines, `.claude/agents/.md`, `.claude/skills//SKILL.md`, opt-in `.claude/settings.json` hooks | 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 | @@ -300,6 +300,16 @@ These file paths are the portable contract. A host-specific installer may write additional generated files, but it may not move the canonical anchor without changing the projection version. +Projection versions are per host. The Claude Code projection is at **v2** +(`CLAUDE_PROJECTION_VERSION`): v1 wrote the coordinator, `.mcp.json`, the +`CLAUDE.md` block and the `.gitignore` lines; v2 adds one subagent per member, +the template and coordinator skills, and the opt-in `settings.json` hooks. +Every generated Claude Markdown file states its version in the generated-file +note (`… Claude projection v2.`). The note keeps the v1 wording as a prefix, so +`squad upgrade` still recognizes v1 files as Squad-owned and replaces them in +place. `settings.json` handlers are identified by their `hook claude ` +args instead, because JSON has no comments. + The `submit` column describes the host execution primitive only. It is not the portable conversation wire contract. MCP-backed projections are still host transport details behind the shared backend semantics. @@ -360,7 +370,73 @@ detail inside the harness adapter rather than a different workspace contract. - agent anchor: `.claude/agents/squad.md` - MCP config: `.mcp.json` -- support files: `CLAUDE.md` +- support files: `CLAUDE.md`, one member subagent `.claude/agents/.md` + per charter in `.squad/agents//charter.md` (built-ins included), and + skills in `.claude/skills//SKILL.md` + +The skills are Squad's template skills (from `.squad/templates/skills/`, +except where a team skill of the same name exists in `.squad/skills/`, which +keeps its precedence) and the coordinator's on-demand `squad-coordinator-*` +skills. The Claude coordinator digest replaces core-protocol items 0 (Init +Mode and casting), 9 (Ralph mode), 15 (Ceremonies), 18 (GitHub Issues mode) +and 19 (PRD mode) with stubs that keep each trigger and the rules that must +hold before the skill is loaded; the full item text moves verbatim into the +matching skill. The shared digest for other hosts and +`.github/agents/squad.agent.md` are unchanged. Skill files remain untrusted +data (core-protocol item 17). + +Member subagents map charter fields onto Claude Code frontmatter: + +| Charter field | Frontmatter | +|---|---| +| `Identity: ID` (or the charter directory name) | `name` | +| `Identity: Purpose` (or name and role), plus the member's `routing.md` work types | `description` | +| `Model: Preferred` | `model`, only for a Claude model or alias; omitted for `auto` | +| `Model: Reasoning Effort` | `effort`; omitted for `auto` | +| `X-squad-host-claude: Tools` | `tools` | +| `X-squad-host-claude: Isolation` (`worktree`) | `isolation` | + +`X-squad-host-claude` is a charter extension (charter v0.1 §8: namespace +`squad`, name `host-claude`). Its values are runtime configuration, not +authorization (charter v0.1 §13.1). A preference the projection cannot apply +(a non-Claude model, an invalid extension value, or `long_context` without a +long-context Claude model) is recorded in a comment in the generated file +rather than dropped silently (execution preferences v0.1 §6). + +Governance hooks are opt-in. With `"claudeHooks": "advisory"` in +`.squad/config.json`, the projection adds one Squad handler per event to +`.claude/settings.json`. Each handler is an exec-form command (no shell) that +runs the MCP launch spec with `hook claude ` in place of `state-mcp`. +The handlers are: + +| Event | Matcher | Effect | +|---|---|---| +| `SessionStart`, `SubagentStart` | `squad` (SubagentStart only) | For the coordinator only: adds context with the CLI version the `squad_state` bridge must match, the roster, routing, active reviewer lockouts and an open Ralph circuit breaker | +| `PreToolUse` | `Edit\|MultiEdit\|Write\|NotebookEdit` | A member listed in an active lockout that edits a path the subject covers gets a warning, shown to the user and to the model | +| `PostToolUse` | `Agent\|Task` | After the coordinator spawns a member: adds a `squad-coordination-handoff/v0.1` envelope with structured fields only (`result` when the call finished; `acknowledgement` for a background launch, `status: "async_launched"`, which has not finished), a reminder to record decisions and run the reviewer gate, and the member's lockout state | +| `Stop` | none | For the coordinator, once per stop (`stop_hook_active` is honoured): asks for Scribe when `decisions/inbox/` has entries, and reports an open Ralph circuit breaker | + +The hooks are advisory: no handler returns a `permissionDecision` or blocks a +stop, so they never grant or deny a tool call (governance-review v0.1 §10). +A handler that fails or finds hooks turned off prints nothing and exits 0. +The decisions inbox is read from disk, so a team on a non-local state backend +gets no Scribe reminder from `Stop`. + +Reviewer lockouts are recorded durably in +`/governance/lockouts.json` by `squad lockout reject|assign|submit|approve|override|status`. +That command implements the governance-review v0.1 §6 states (`author-locked`, +`revision-assigned`, `revision-submitted`, `cleared`, `deadlocked`). An +override needs an authority (the user, or a member listed in +`governance.authorities` in `config.json`) and a reason. Revision authors are +the members with a charter, except the coordinator, Scribe, Ralph, Rai and +Fact Checker, whose charters exclude revision pairing. Each command reads, +transitions and writes the record under an interprocess lock, so concurrent +commands keep every transition. A `governance/` directory or file that resolves +outside the team directory is refused. + +Projected Claude files never copy content from outside the team directory: a +charter, `routing.md` or template `SKILL.md` that resolves outside it through a +symlink is skipped. ### 7.2 `submit` @@ -381,8 +457,9 @@ run identity. ### 7.3 `projectWorkspace` The adapter reports the repository root, current working directory, and the -pair `.claude/agents/squad.md` plus `CLAUDE.md` so the broker can verify that -the projected host files are in sync with the portable Squad contract. +pair `.claude/agents/squad.md` plus `CLAUDE.md`, with the member subagents +under `supportFiles`, so the broker can verify that the projected host files +are in sync with the portable Squad contract. ## 8. Kiro projection @@ -512,11 +589,25 @@ Companion-file ownership is explicit: - `.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; +- `.claude/agents/.md` member subagents and `.claude/skills//SKILL.md` + skills are Squad-managed only while they + carry the generated-file comment: a user-authored file at the same path is + never overwritten or removed (the apply result is `skipped`). Upgrade + removes the generated file of a member whose charter is gone (or of a skill + that is no longer projected), and rollback removes every generated file; +- `.claude/settings.json` is user-owned. Squad owns only its hook handlers, + identified by args ending in `hook claude `. Upgrade replaces those + handlers and keeps every other setting and hook. Turning `claudeHooks` off + removes them on the next upgrade. Rollback removes them and restores the + remaining JSON, and deletes the file only when nothing else was in it. + With hooks off and no settings file, nothing is written; - 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. + byte-preserving rollback outside those managed regions. A block is appended after one blank line, and keeps + the file's missing final newline missing, so removing it restores the + original bytes however the file ended. Merge and upgrade behavior follows the same rule: full ownership allows replacement with the current projection version, while marker-scoped ownership diff --git a/docs/src/content/docs/features/ralph.md b/docs/src/content/docs/features/ralph.md index cc846e358..c8ed00fa6 100644 --- a/docs/src/content/docs/features/ralph.md +++ b/docs/src/content/docs/features/ralph.md @@ -191,13 +191,15 @@ All new features are **opt-in** and disabled by default. Existing `squad watch` | Flag | Description | Example | |------|-------------|---------| -| `--execute` | Enable work execution (spawn Copilot to work on issues) | `squad watch --execute` | +| `--execute` | Enable work execution (spawn Copilot, or Claude Code with `--agent-preset claude`, to work on issues) | `squad watch --execute` | | `--max-concurrent N` | Max parallel issues per round (default: 1) | `squad watch --execute --max-concurrent 3` | | `--timeout N` | Per-issue timeout in minutes (default: 30) | `squad watch --execute --timeout 45` | | `--max-rounds N` | Stop after N completed rounds | `squad watch --max-rounds 20` | | `--max-duration D` | Stop after elapsed time; finish an active round first | `squad watch --max-duration 8h` | | `--claims ` | Claim each issue before dispatch (Git ref compare-and-swap on `origin`) so several watchers never work on the same issue. Default `none`. Config: `watch.claims` (`"git-ref"` or `{ "provider": "git-ref", "ttlMs", "skewMarginMs", "remote", "refPrefix" }`); the flag wins. See ADR-002. | `squad watch --execute --claims git-ref` | | `--copilot-flags "..."` | Pass extra flags to Copilot CLI | `squad watch --execute --copilot-flags "--model gpt-5.6-luna"` | +| `--agent-preset ` | Runner for each round: `copilot` (default) or `claude` (Claude Code). Config: `watch.agentPreset`. See [Claude Code runner](#claude-code-runner---agent-preset-claude). | `squad watch --execute --agent-preset claude` | +| `--claude-flags "..."` | Flags for the `claude` preset; replaces the execute-mode default `--permission-mode bypassPermissions`. Config: `watch.claudeFlags`. | `squad watch --execute --agent-preset claude --claude-flags "--permission-mode acceptEdits"` | With claims on, each round report adds a claims line when anything happened (for example `🔒 Claims (git-ref): won 2 · contended 1 · released 2 (completed 2)`): claims won, held by another watcher (`contended`), `unavailable`, `fenced` (taken over while held), `lost` (not renewed before expiry; agent killed), `released` by outcome, and `implausible` records taken over. `squad watch --health` shows the same counters since startup when the watch records a PID file. With an OTLP endpoint (`OTEL_EXPORTER_OTLP_ENDPOINT`) or `SQUAD_TELEMETRY=1`, they are also emitted as OpenTelemetry counters `squad.watch.claims.{acquired,contended,unavailable,fenced,lost,released,implausible_takeover}` with attributes `claim.provider` and, for `released`, `claim.outcome`. No issue numbers, hostnames, or actors are recorded. With `none`, output is unchanged. To run watchers on several machines or pods, see [Multi-Machine Watch with Work Claims](../scenarios/multi-machine-watch.md). @@ -259,6 +261,43 @@ When all features are enabled, each round follows this cycle: 6. **Monitor**: Scan Teams/email for new actionable items 7. **Housekeep**: Check for retro, merge decision inbox if needed 8. **Report**: Log round summary, sleep until next interval +### Claude Code runner: `--agent-preset claude` + +By default `--execute` runs each round through the Copilot CLI. To run it +through Claude Code instead, project the team for Claude and pick the preset: + +```bash +squad upgrade --client claude +squad watch --execute --agent-preset claude +``` + +Or set it once in `.squad/config.json`: + +```json +{ "watch": { "agentPreset": "claude" } } +``` + +Each round runs `claude -p "" --agent squad --output-format stream-json +--verbose`, so the projected Squad coordinator handles the issues. The watcher +passes `--mcp-config` with a private config that holds only `squad_state`, +resolved by the `squad` CLI from its own install. It never passes your +repository's `.mcp.json`, so any other project MCP servers still need Claude +Code's usual approval. Preflight fails if `.claude/agents/squad.md` or the +`claude` CLI is missing. A run that exits cleanly but reports an error in its +final `result` event (for example `error_max_turns`) counts as a failed round; +this applies to monitor, retro and decision-hygiene runs too. + +Execute mode adds `--permission-mode bypassPermissions` by default, the +counterpart of Copilot's `--yolo`. Pass your own flags with +`--claude-flags "--permission-mode acceptEdits --model sonnet"` (or +`watch.claudeFlags`) to replace that default. Because `.squad/config.json` +belongs to the repository, `watch.claudeFlags` may only set +`--permission-mode`, `--model`, `--fallback-model`, `--effort` and +`--max-turns`; tool lists and flags that change MCP servers, settings, +plugins, directories or the agent are refused there and must come from +`--claude-flags`. `--agent-cmd` still overrides the +preset, and `--dispatch-mode fleet` stays Copilot-only. + ### Advanced: `--agent-cmd` (Hidden Flag) For advanced users who know what they're doing: ```bash diff --git a/docs/src/content/docs/reference/host-feature-matrix.md b/docs/src/content/docs/reference/host-feature-matrix.md index c81ef9c47..a78a66d93 100644 --- a/docs/src/content/docs/reference/host-feature-matrix.md +++ b/docs/src/content/docs/reference/host-feature-matrix.md @@ -68,7 +68,7 @@ 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 | 0 | 49 | 2 | 0 | 0 | 6 | -| `claude` | 41 | 1 | 6 | 3 | 18 | 30 | 3 | 0 | 0 | 6 | +| `claude` | 43 | 0 | 5 | 3 | 18 | 30 | 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 | @@ -85,7 +85,7 @@ These digest gaps are pinned as expected failures, one test per host and gap: th | [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. | | [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` `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. | | [marketplace-plugins](#marketplace-plugins) | `test/clients.test.ts::digest gap (expected until the stack adds it): plugin marketplace browse on cast` (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. | ## Known limitations of this evidence @@ -104,7 +104,7 @@ These digest gaps are pinned as expected failures, one test per host and gap: th | [Init Mode & casting (`squad init`, `squad cast --name`, casting registry)](#init-mode-casting) | a, b | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Work routing by ownership (`routing.md`, one accountable owner, named requests)](#routing-ownership) | b | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Response modes (Direct / Lightweight / Standard / Full) + "feels heard" acknowledgement](#response-modes) | b | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | -| [Model / reasoning-effort / context-tier selection per spawn](#model-selection) | b, d | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | +| [Model / reasoning-effort / context-tier selection per spawn](#model-selection) | b, d | ❌ 🧪 | ✅ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | ❌ 🧪 | | [Parallel fan-out of independent specialists](#parallel-fanout) | b, d | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Background agents (Scribe/Rai run non-blocking)](#background-agents) | d | ✅ 🧪 | ✅ 🖥️ | ⚠️ 🧪 | ✅ 🖥️ | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | | [Skills (5 roots, precedence, untrusted-data rule)](#skills-precedence) | b | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | @@ -150,7 +150,7 @@ These digest gaps are pinned as expected failures, one test per host and gap: th |---------|------|---|---|---|---|---|---|---| | [@copilot coding agent member (`squad copilot`, GitHub assignment)](#copilot-coding-agent) | a, b, e | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Ralph — interactive work monitor ("Ralph, go")](#ralph-coordinator-loop) | b, e | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | -| [Ralph CLI (`squad triage` / `watch` / `loop` --execute)](#ralph-cli-triage-watch) | a, e (host-independent) | ✅ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | +| [Ralph CLI (`squad triage` / `watch` / `loop` --execute)](#ralph-cli-triage-watch) | a, e (host-independent) | ✅ 🧪 | ✅ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | ⚠️ 🧪 | | [GitHub Issues mode (connect, list, route, branch → PR → merge)](#github-issues-mode) | b, e | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Labels & triage (`squad` / `squad:` taxonomy)](#labels-triage) | a, b, e | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Project boards (`squad triage --board`)](#project-boards) | a, e (host-independent) | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | @@ -204,7 +204,7 @@ Depends on: **a, b** · docs: [features/team-setup](/squad/docs/features/team-se | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | CLI scaffolds `.squad/`; coordinator Init Mode (digest §0) uses `squad_state_read team.md`, auto-confirms headless, casts via `squad cast --name`, never hand-edits `.squad/casting/`. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::field findings from the live demo: Init Mode, scratch files, casting registry`
`validated-test:test/cli/init.test.ts::*`
`static-only` | -| `claude` | ✅ supported | 🖥️ validated-live | CLI scaffolds `.squad/`; coordinator Init Mode (digest §0) uses `squad_state_read team.md`, auto-confirms headless, casts via `squad cast --name`, never hand-edits `.squad/casting/`. _Live: cast 4 members via `squad cast --name` (registry pair valid); ignored the requested descriptive universe and used Apollo 13 names._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::field findings from the live demo: Init Mode, scratch files, casting registry`
`validated-test:test/cli/init.test.ts::*`
`validated-manual:lab:init-mode@2026-10-22` | +| `claude` | ✅ supported | 🖥️ validated-live | CLI scaffolds `.squad/`; the digest stub (§0) decides Init Mode with `squad_state_read team.md`, never glob, and loads the `squad-coordinator-init-mode` skill, which auto-confirms headless, casts via `squad cast --name` and never hand-edits `.squad/casting/`. _Live: cast 4 members via `squad cast --name` (registry pair valid); ignored the requested descriptive universe and used Apollo 13 names._ | `validated-test:test/claude-skills.test.ts::*`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::field findings from the live demo: Init Mode, scratch files, casting registry`
`validated-test:test/cli/init.test.ts::*`
`validated-manual:lab:init-mode@2026-10-22` | | `kiro` | ✅ supported | 🧪 validated-test | CLI scaffolds `.squad/`; coordinator Init Mode (digest §0) uses `squad_state_read team.md`, auto-confirms headless, casts via `squad cast --name`, never hand-edits `.squad/casting/`. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::field findings from the live demo: Init Mode, scratch files, casting registry`
`validated-test:test/cli/init.test.ts::*`
`static-only` | | `opencode` | ✅ supported | 🖥️ validated-live | CLI scaffolds `.squad/`; coordinator Init Mode (digest §0) uses `squad_state_read team.md`, auto-confirms headless, casts via `squad cast --name`, never hand-edits `.squad/casting/`. _Live: cast 4 members via `squad cast --name` with descriptive names; registry pair valid._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::field findings from the live demo: Init Mode, scratch files, casting registry`
`validated-test:test/cli/init.test.ts::*`
`validated-manual:lab:init-mode@2026-10-22` | | `cursor` | ✅ supported | 🧪 validated-test | CLI scaffolds `.squad/`; coordinator Init Mode (digest §0) uses `squad_state_read team.md`, auto-confirms headless, casts via `squad cast --name`, never hand-edits `.squad/casting/`. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::field findings from the live demo: Init Mode, scratch files, casting registry`
`validated-test:test/cli/init.test.ts::*`
`static-only` | @@ -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) | 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` | +| `claude` | ✅ supported | 🧪 validated-test | Each member's `.claude/agents/.md` carries the charter's `Preferred` model (Claude models and aliases only) and the reasoning effort resolved from `.squad/config.json` (`agentReasoningEffortOverrides`, `defaultReasoningEffort`) over the charter. The Claude dispatch section applies the higher-precedence model layers (user directive, `agentModelOverrides`, `defaultModel`, session choice) as the per-call `Agent` `model`. A non-Claude model, a per-spawn effort directive (the `Agent` tool has no per-call effort) and `long_context` without a long-context Claude model are reported, not applied. | `validated-test:test/claude-members.test.ts::*`
`validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix` | | `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` | @@ -364,7 +364,7 @@ Depends on: **b** · docs: [features/skills](/squad/docs/features/skills/) · ca | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/skills.test.ts::*`
`static-only` | -| `claude` | ✅ supported | 🖥️ validated-live | Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/skills.test.ts::*`
`validated-manual:lab:skills-precedence@2026-10-22` | +| `claude` | ✅ supported | 🖥️ validated-live | Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence. Squad's template skills are also projected as native skills in `.claude/skills/`, except where a team skill of the same name exists in `.squad/skills/`. | `validated-test:test/claude-skills.test.ts::*`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/skills.test.ts::*`
`validated-manual:lab:skills-precedence@2026-10-22` | | `kiro` | ✅ supported | 🧪 validated-test | Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/skills.test.ts::*`
`static-only` | | `opencode` | ✅ supported | 🖥️ validated-live | Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/skills.test.ts::*`
`validated-manual:lab:skills-precedence@2026-10-22` | | `cursor` | ✅ supported | 🧪 validated-test | Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/skills.test.ts::*`
`static-only` | @@ -380,7 +380,7 @@ Depends on: **b** · docs: [features/reviewer-protocol](/squad/docs/features/rev | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | Digest §12: REJECT locks out the author; revision reassigned; re-review required. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | -| `claude` | ✅ supported | 🖥️ validated-live | Digest §12: REJECT locks out the author; revision reassigned; re-review required. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-manual:lab:reviewer-lockout@2026-10-22` | +| `claude` | ✅ supported | 🖥️ validated-live | Digest §12: REJECT locks out the author; revision reassigned; re-review required. `squad lockout` records the §6 lockout state in `.squad/governance/lockouts.json`; with `claudeHooks: "advisory"`, a PreToolUse hook warns a locked-out member that edits a covered path (advisory: never denies). | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-test:test/claude-hooks.test.ts::runs reject → assign → submit → approve, and approval clears the lockout`
`validated-test:test/claude-hooks.test.ts::PreToolUse warns when a locked-out member edits a subject file, and never denies`
`validated-manual:lab:reviewer-lockout@2026-10-22` | | `kiro` | ✅ supported | 🧪 validated-test | Digest §12: REJECT locks out the author; revision reassigned; re-review required. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `opencode` | ✅ supported | 🖥️ validated-live | Digest §12: REJECT locks out the author; revision reassigned; re-review required. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-manual:lab:reviewer-lockout@2026-10-22` | | `cursor` | ✅ supported | 🧪 validated-test | Digest §12: REJECT locks out the author; revision reassigned; re-review required. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | @@ -428,7 +428,7 @@ Depends on: **b, c** · docs: [features/ceremonies](/squad/docs/features/ceremon | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | Digest §15 reads `ceremonies.md`, spawns a facilitator, records via `squad_decide`. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | -| `claude` | ✅ supported | 🧪 validated-test | Digest §15 reads `ceremonies.md`, spawns a facilitator, records via `squad_decide`. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | +| `claude` | ✅ supported | 🧪 validated-test | The digest stub (§15) loads the `squad-coordinator-ceremonies` skill, which reads `ceremonies.md`, spawns a facilitator and records via `squad_decide`. | `validated-test:test/claude-skills.test.ts::*`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `kiro` | ✅ supported | 🧪 validated-test | Digest §15 reads `ceremonies.md`, spawns a facilitator, records via `squad_decide`. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `opencode` | ✅ supported | 🧪 validated-test | Digest §15 reads `ceremonies.md`, spawns a facilitator, records via `squad_decide`. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `cursor` | ✅ supported | 🧪 validated-test | Digest §15 reads `ceremonies.md`, spawns a facilitator, records via `squad_decide`. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | @@ -476,7 +476,7 @@ Depends on: **b, e** · docs: [features/ralph](/squad/docs/features/ralph/) · c | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | Digest §9: coordinator runs the loop itself via `gh`; circuit breaker template. Requires `gh` authenticated inside the host. | `validated-test:test/clients.test.ts::coordinator Ralph mode`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | -| `claude` | ✅ supported | 🖥️ validated-live | Digest §9: coordinator runs the loop itself via `gh`; circuit breaker template. Requires `gh` authenticated inside the host. | `validated-test:test/clients.test.ts::coordinator Ralph mode`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-manual:lab:ralph-no-repo@2026-10-22` | +| `claude` | ✅ supported | 🖥️ validated-live | The digest stub (§9) loads the `squad-coordinator-ralph-mode` skill; the coordinator runs the loop itself via `gh`, with the circuit breaker template. Requires `gh` authenticated inside the host. | `validated-test:test/claude-skills.test.ts::*`
`validated-test:test/clients.test.ts::coordinator Ralph mode`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-manual:lab:ralph-no-repo@2026-10-22` | | `kiro` | ✅ supported | 🧪 validated-test | Digest §9: coordinator runs the loop itself via `gh`; circuit breaker template. Requires `gh` authenticated inside the host. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator Ralph mode`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `opencode` | ✅ supported | 🖥️ validated-live | Digest §9: coordinator runs the loop itself via `gh`; circuit breaker template. Requires `gh` authenticated inside the host. | `validated-test:test/clients.test.ts::coordinator Ralph mode`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`validated-manual:lab:ralph-no-repo@2026-10-22` | | `cursor` | ✅ supported | 🧪 validated-test | Digest §9: coordinator runs the loop itself via `gh`; circuit breaker template. Requires `gh` authenticated inside the host. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator Ralph mode`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | @@ -492,7 +492,7 @@ Depends on: **a, e** (host-independent) · docs: [features/ralph](/squad/docs/fe | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | Default `agentCmd` is the Copilot CLI (`copilot -p …`). | `validated-test:test/ralph-triage.test.ts::*`
`validated-test:test/ralph-monitor.test.ts::*`
`validated-test:test/watch-fleet-dispatch.test.ts::*` | -| `claude` | ⚠️ degraded | 🧪 validated-test | Scanning/routing is host-agnostic, but `--execute` dispatches through the Copilot CLI unless `agentCmd` (`--agent-cmd " … {prompt}"`) is configured; no built-in preset for this host. | `validated-test:test/ralph-triage.test.ts::*`
`static-only` | +| `claude` | ✅ supported | 🧪 validated-test | Built-in preset: `--agent-preset claude` (or `watch.agentPreset: "claude"`) runs `claude -p {prompt} --agent squad --mcp-config --output-format stream-json --verbose` (the config is written by the CLI; the repository's `.mcp.json` is never passed); execute mode defaults to `--permission-mode bypassPermissions`. Fleet dispatch stays Copilot-only. | `validated-test:test/ralph-triage.test.ts::*`
`validated-test:test/cli/watch-claude-preset.test.ts::*` | | `kiro` | ⚠️ degraded | 🧪 validated-test | Scanning/routing is host-agnostic, but `--execute` dispatches through the Copilot CLI unless `agentCmd` (`--agent-cmd " … {prompt}"`) is configured; no built-in preset for this host. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `validated-test:test/ralph-triage.test.ts::*`
`static-only` | | `opencode` | ⚠️ degraded | 🧪 validated-test | Scanning/routing is host-agnostic, but `--execute` dispatches through the Copilot CLI unless `agentCmd` (`--agent-cmd " … {prompt}"`) is configured; no built-in preset for this host. | `validated-test:test/ralph-triage.test.ts::*`
`static-only` | | `cursor` | ⚠️ degraded | 🧪 validated-test | Scanning/routing is host-agnostic, but `--execute` dispatches through the Copilot CLI unless `agentCmd` (`--agent-cmd " … {prompt}"`) is configured; no built-in preset for this host. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `validated-test:test/ralph-triage.test.ts::*`
`static-only` | @@ -508,7 +508,7 @@ Depends on: **b** · docs: [features/prd-mode](/squad/docs/features/prd-mode/) | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | Digest §19; `prd-intake.md` template on demand. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | -| `claude` | ✅ supported | 🧪 validated-test | Digest §19; `prd-intake.md` template on demand. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | +| `claude` | ✅ supported | 🧪 validated-test | The digest stub (§19) loads the `squad-coordinator-prd-mode` skill; `prd-intake.md` template on demand. | `validated-test:test/claude-skills.test.ts::*`
`validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `kiro` | ✅ supported | 🧪 validated-test | Digest §19; `prd-intake.md` template on demand. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `opencode` | ✅ supported | 🧪 validated-test | Digest §19; `prd-intake.md` template on demand. | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | | `cursor` | ✅ supported | 🧪 validated-test | Digest §19; `prd-intake.md` template on demand. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `validated-test:test/clients.test.ts::coordinator behavioral-contract parity`
`static-only` | @@ -876,7 +876,7 @@ Depends on: **e** · docs: [guide/gh-aw](/squad/docs/guide/gh-aw/) | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `copilot` | ✅ supported | 🧪 validated-test | Workflows in `workflows/*.md`; verifier + e2e scripts. | `validated-test:test/gh-aw-enlistment-skill.test.ts::*`
`static-only` | -| `claude` | — n/a | 📄 static-only | Runs in GitHub Actions with the Copilot engine; the local host is irrelevant. | `static-only` | +| `claude` | — n/a | 📄 static-only | Runs in GitHub Actions, so the local host is irrelevant. Squad's workflows use the Copilot engine; gh-aw also has a built-in Claude Code engine (`engine: claude`), which Squad does not ship a workflow for. _Re-evaluated for #2442 (research: https://github.com/github/squad/issues/2438#issuecomment-6753729924). A Squad workflow on `engine: claude` would pass `engine.args: ["--agent", "squad"]` (gh-aw has no native agent selection for Claude) and needs `bare: false` for subagent delegation; not yet run._ | `static-only` | | `kiro` | — n/a | 📄 static-only | Runs in GitHub Actions with the Copilot engine; the local host is irrelevant. _Live run blocked on 2026-10-22: kiro quota/usage limit reached on the reference machine._ | `static-only` | | `opencode` | — n/a | 📄 static-only | Runs in GitHub Actions with the Copilot engine; the local host is irrelevant. | `static-only` | | `cursor` | — n/a | 📄 static-only | Runs in GitHub Actions with the Copilot engine; the local host is irrelevant. _Live run blocked on 2026-10-22: cursor quota/usage limit reached on the reference machine._ | `static-only` | diff --git a/docs/src/content/docs/scenarios/client-compatibility.md b/docs/src/content/docs/scenarios/client-compatibility.md index d75c2e182..8355d7440 100644 --- a/docs/src/content/docs/scenarios/client-compatibility.md +++ b/docs/src/content/docs/scenarios/client-compatibility.md @@ -193,9 +193,12 @@ Squad runs on VS Code with **conditional support**. Key differences from CLI: ### What Squad projects - `.claude/agents/squad.md` — the coordinator, as a Claude Code subagent named `squad` +- `.claude/agents/.md` — one subagent per team member, built-ins included, generated from `.squad/agents//charter.md`. `squad cast` adds the new member's file. If you already have your own file at one of these paths, Squad leaves it alone and reports it as skipped. +- `.claude/skills//SKILL.md` — Squad's skills as native Claude Code skills, plus `squad-coordinator-*` skills the coordinator loads on demand (Init Mode, Ralph, ceremonies, GitHub Issues and PRD modes), which keeps its always-loaded prompt smaller. A team skill of the same name in `.squad/skills/` takes precedence, so that template isn't projected. Your own skills in `.claude/skills/` are left alone. - `.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 +- `.claude/settings.json` — Squad's governance hooks, only if you opt in (see below). Your other settings and hooks are kept. ### Starting a session @@ -209,14 +212,37 @@ Interactive Claude Code doesn't load project-scoped `.mcp.json` servers until yo | 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. | +| **Sub-agent spawning** | ✅ `Agent` tool | Each member runs as its own subagent (`subagent_type: ""`), which reads its charter. 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). | +| **Per-spawn model selection** | ✅ | A member's subagent uses the Claude model in its charter's `Preferred` field, and the reasoning effort from `.squad/config.json` or the charter. The coordinator passes a per-call `model` when your request, `agentModelOverrides`, `defaultModel` or the session's model takes precedence. Re-run `squad upgrade` after changing reasoning-effort config. A non-Claude model, a one-off effort request and `long_context` without a long-context Claude model are reported rather than applied. | | **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). | +| **Hooks** | ✅ Opt-in, advisory | Off by default. See [Governance hooks](#governance-hooks). | +| **`squad watch --execute`** | ✅ Preset | `squad watch --execute --agent-preset claude` (or `"agentPreset": "claude"` under `watch` in `.squad/config.json`) runs each round as `claude -p … --agent squad` with a `squad_state`-only MCP config written by the `squad` CLI (never the repository's `.mcp.json`, whose other servers keep Claude Code's approval). Execute mode defaults to `--permission-mode bypassPermissions`, the counterpart of Copilot's `--yolo`; override it with `--claude-flags`. Fleet dispatch (`--dispatch-mode fleet`) stays Copilot-only. | + +### Governance hooks + +To turn the hooks on, add `"claudeHooks": "advisory"` to `.squad/config.json` and run `squad upgrade --client claude`. Squad then adds these hooks to `.claude/settings.json`: + +- **Session start:** the coordinator starts with the roster, routing, active reviewer lockouts and the CLI version its `squad_state` bridge must match. +- **Before an edit:** a member who is locked out of a review subject gets a warning when it edits a file that subject covers. You see the warning too. +- **After a member finishes:** the coordinator gets a handoff summary and a reminder to record decisions and run the reviewer gate. +- **When the coordinator stops:** it's reminded to run Scribe if `.squad/decisions/inbox/` has entries, and you're told if Ralph's circuit breaker is open. + +The hooks only warn. They never block a tool call or permission, and they print nothing when hooks are off. To turn them off, set `"claudeHooks": "off"` and run `squad upgrade --client claude` again. Squad removes its hooks and leaves the rest of `.claude/settings.json` as it was. + +Reviewer lockouts come from `squad lockout`. For example, after Ripley rejects Dallas's work on issue 42: + +```bash +squad lockout reject issue-42 --author dallas --reviewer ripley --reason "no tests for the auth path" --paths "src/auth/**" +``` + +```bash +squad lockout assign issue-42 --successor lambert +``` + +`squad lockout status` lists each subject with its state and who can take the next revision. `submit` and `approve` record the revision and its approval, which ends the lockout. `override` lets you, or a member listed in `governance.authorities` in `.squad/config.json`, let a locked-out author back in, with a reason. If no eligible member is left, the subject is marked deadlocked until someone records an override. --- diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 15be95209..1e1a17183 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -250,6 +250,7 @@ async function main(): Promise { console.log(` Usage: memory write --content "..." --class LOCAL`); console.log(` Diagnostics: --log-level info|debug or --verbose`); console.log(` ${BOLD}state-mcp${RESET} MCP bridge exposing Squad runtime state tools`); + console.log(` ${BOLD}lockout${RESET} Reviewer lockouts: reject|assign|submit|approve|override|status`); console.log(` ${BOLD}doctor${RESET} Validate squad setup (check files, config, health)`); console.log(` ${BOLD}health${RESET} Validate team state for CI (team/registry/routing/backend/env)`); console.log(` Flags: --json (structured output for CI)`); @@ -556,6 +557,16 @@ async function main(): Promise { return; } + if (cmd === 'hook') { + const { runHookCommand } = await import('./cli/commands/hook.js'); + process.exit(await runHookCommand(args.slice(1))); + } + + if (cmd === 'lockout') { + const { runLockoutCommand } = await import('./cli/commands/lockout.js'); + process.exit(await runLockoutCommand(getSquadStartDir(), args.slice(1))); + } + if (cmd === 'state-mcp') { const { runStateMcp } = await import('./cli/commands/state-mcp.js'); await runStateMcp(getSquadStartDir()); @@ -628,6 +639,19 @@ async function main(): Promise { ? args[agentCmdIdx + 1] : undefined; + const agentPresetIdx = args.indexOf('--agent-preset'); + const rawAgentPreset = agentPresetIdx !== -1 ? args[agentPresetIdx + 1] : undefined; + if (agentPresetIdx !== -1 && rawAgentPreset !== 'copilot' && rawAgentPreset !== 'claude') { + console.error(`\u26a0\ufe0f --agent-preset must be "copilot" or "claude"${rawAgentPreset ? ` (got "${rawAgentPreset}")` : ''}`); + process.exit(1); + } + const agentPreset = rawAgentPreset as 'copilot' | 'claude' | undefined; + + const claudeFlagsIdx = args.indexOf('--claude-flags'); + const claudeFlags = (claudeFlagsIdx !== -1 && args[claudeFlagsIdx + 1]) + ? args[claudeFlagsIdx + 1] + : undefined; + const maxConcurrentIdx = args.indexOf('--max-concurrent'); const maxConcurrent = (maxConcurrentIdx !== -1 && args[maxConcurrentIdx + 1]) ? parseInt(args[maxConcurrentIdx + 1]!, 10) @@ -752,6 +776,8 @@ async function main(): Promise { timeout, copilotFlags, agentCmd, + agentPreset, + claudeFlags, verbose, dispatchMode, logFile, @@ -774,7 +800,7 @@ async function main(): Promise { // After parsing all flags, check for positional args that look like prompts. // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional). const knownValueFlags = new Set([ - '--interval', '--max-rounds', '--max-duration', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--board-owner', '--auth-user', + '--interval', '--max-rounds', '--max-duration', '--copilot-flags', '--agent-cmd', '--agent-preset', '--claude-flags', '--max-concurrent', '--timeout', '--board-project', '--board-owner', '--auth-user', '--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend', '--claims', ]); diff --git a/packages/squad-cli/src/cli/commands/cast.ts b/packages/squad-cli/src/cli/commands/cast.ts index 70aeec3fc..2552b1159 100644 --- a/packages/squad-cli/src/cli/commands/cast.ts +++ b/packages/squad-cli/src/cli/commands/cast.ts @@ -364,6 +364,10 @@ export async function runCastAdd(cwd: string, options: CastAddOptions): Promise< info(` Charter: ${charterPath}`); info(` History: ${historyPath}`); + // Claude Code gets one subagent per member: refresh the projection so the new member is spawnable. + // Local mode only; remote and external state refresh on the next `squad upgrade`. + if (!externalSquadDir && paths.mode === 'local') await refreshClaudeMembers(teamRoot, squadDir); + return { agentId: slug, agentDir, @@ -372,6 +376,21 @@ export async function runCastAdd(cwd: string, options: CastAddOptions): Promise< }; } +async function refreshClaudeMembers(root: string, squadDir: string): Promise { + try { + // Lazy import: most cast invocations have no client projections to refresh. + const { readConfiguredClients, syncClients, reportClientResults } = await import('../core/clients.js'); + if (!readConfiguredClients(squadDir).includes('claude')) return; + const synced = await syncClients(root, squadDir); + reportClientResults( + synced.results.filter((r) => r.path.startsWith('.claude/agents/')), + (m) => info(` ${m}`), + ); + } catch (err) { + info(` Could not refresh Claude Code subagents (run \`squad upgrade\`): ${err instanceof Error ? err.message : String(err)}`); + } +} + /** * Run the cast command — show merged session cast. */ diff --git a/packages/squad-cli/src/cli/commands/hook.ts b/packages/squad-cli/src/cli/commands/hook.ts new file mode 100644 index 000000000..d75a6227a --- /dev/null +++ b/packages/squad-cli/src/cli/commands/hook.ts @@ -0,0 +1,54 @@ +/** + * `squad hook claude `: the Claude Code hook entry point (#2438 Phase 3). + * + * Reads the hook event JSON on stdin and prints advisory JSON output. It never blocks: any error + * prints nothing and exits 0, so a broken hook cannot stop a session. + */ +import { resolveSquadState } from '@bradygaster/squad-sdk'; +import { CLAUDE_HOOK_EVENTS, runClaudeHook } from '@bradygaster/squad-sdk/client'; +import { getPackageVersion } from '../core/version.js'; + +const STDIN_TIMEOUT_MS = 5_000; + +async function readStdin(): Promise { + if (process.stdin.isTTY) return ''; + return new Promise((resolve) => { + const chunks: Buffer[] = []; + const done = () => resolve(Buffer.concat(chunks).toString('utf8')); + const timer = setTimeout(done, STDIN_TIMEOUT_MS); + process.stdin.on('data', (c: Buffer) => chunks.push(c)); + process.stdin.on('end', () => { + clearTimeout(timer); + done(); + }); + process.stdin.on('error', () => { + clearTimeout(timer); + done(); + }); + }); +} + +export async function runHookCommand(args: string[]): Promise { + const [host, event] = args; + if (host !== 'claude' || !event || !(CLAUDE_HOOK_EVENTS as readonly string[]).includes(event)) { + process.stderr.write(`Usage: squad hook claude <${CLAUDE_HOOK_EVENTS.join('|')}>\n`); + return 0; + } + try { + const raw = await readStdin(); + const input = raw.trim() ? (JSON.parse(raw) as Record) : {}; + const start = process.env['CLAUDE_PROJECT_DIR'] || (typeof input['cwd'] === 'string' ? input['cwd'] : '') || process.cwd(); + const state = resolveSquadState(start); + if (!state) return 0; + const out = runClaudeHook(event, input, { + root: state.repoRoot, + teamAbs: state.paths.teamSquadDir, + projectAbs: state.paths.projectDir, + version: getPackageVersion(), + }); + if (out) process.stdout.write(`${JSON.stringify(out)}\n`); + } catch (err) { + process.stderr.write(`squad hook: ${err instanceof Error ? err.message : String(err)}\n`); + } + return 0; +} diff --git a/packages/squad-cli/src/cli/commands/lockout.ts b/packages/squad-cli/src/cli/commands/lockout.ts new file mode 100644 index 000000000..7202822df --- /dev/null +++ b/packages/squad-cli/src/cli/commands/lockout.ts @@ -0,0 +1,95 @@ +/** + * `squad lockout`: record reviewer-lockout transitions (governance-review v0.1 §6) in + * `/governance/lockouts.json`, which the Claude Code PreToolUse hook reads. + */ +import { resolveSquadState } from '@bradygaster/squad-sdk'; +import { + LockoutError, + approveRevision, + assignRevision, + effectiveLocked, + eligibleSuccessors, + lockoutAuthorities, + lockoutRoster, + overrideLockout, + readLockouts, + rejectRevision, + submitRevision, + updateLockouts, + type LockoutStore, +} from '@bradygaster/squad-sdk/client'; + +const USAGE = `Usage: squad lockout [options] + + reject --author --reviewer --reason "" [--paths ] + assign --successor + submit --author + approve --reviewer + override --author --by --reason "" + status [subject] [--json]`; + +function flag(args: string[], name: string): string | undefined { + const i = args.indexOf(`--${name}`); + return i !== -1 ? args[i + 1] : undefined; +} + +function need(args: string[], name: string): string { + const v = flag(args, name); + if (!v) throw new LockoutError(`--${name} is required`); + return v; +} + +export async function runLockoutCommand(startDir: string, args: string[]): Promise { + const [cmd, second] = args; + // Only a positional second argument is a subject (`status --json` lists every record). + const subject = second && !second.startsWith('--') ? second : undefined; + if (!cmd || cmd === '--help' || cmd === '-h') { + console.log(USAGE); + return cmd ? 0 : 1; + } + const state = resolveSquadState(startDir); + if (!state) { + console.error('No squad found. Run `squad init` first.'); + return 1; + } + const team = state.paths.teamSquadDir; + try { + const roster = lockoutRoster(team); + if (cmd === 'status') { + const store = readLockouts(team); + const shown = store.subjects.filter((s) => !subject || s.subject === subject); + if (args.includes('--json')) console.log(JSON.stringify(shown, null, 2)); + else if (shown.length === 0) console.log('No lockout records.'); + else + for (const r of shown) { + const locked = effectiveLocked(r); + console.log(`${r.subject}: ${r.state}${locked.length ? `, locked out: ${locked.join(', ')}` : ''}${r.successor ? `, successor: ${r.successor}` : ''}`); + if (r.state === 'author-locked' || r.state === 'deadlocked') console.log(` eligible successors: ${eligibleSuccessors(r, roster).join(', ') || '(none)'}`); + } + return 0; + } + if (!subject) throw new LockoutError('A review subject is required'); + let transition: (store: LockoutStore) => LockoutStore; + if (cmd === 'reject') { + const paths = flag(args, 'paths')?.split(',').map((p) => p.trim()).filter(Boolean); + transition = (store) => rejectRevision(store, { subject, author: need(args, 'author'), reviewer: need(args, 'reviewer'), reason: need(args, 'reason'), paths, roster }); + } else if (cmd === 'assign') transition = (store) => assignRevision(store, { subject, successor: need(args, 'successor'), roster }); + else if (cmd === 'submit') transition = (store) => submitRevision(store, { subject, author: need(args, 'author') }); + else if (cmd === 'approve') transition = (store) => approveRevision(store, { subject, reviewer: need(args, 'reviewer') }); + else if (cmd === 'override') + transition = (store) => overrideLockout(store, { subject, author: need(args, 'author'), by: need(args, 'by'), reason: need(args, 'reason'), authorities: lockoutAuthorities(team), roster }); + else { + console.error(USAGE); + return 1; + } + // Read, transition and write under one interprocess lock. + const next = updateLockouts(team, transition); + const r = next.subjects.find((s) => s.subject === subject)!; + console.log(`${r.subject}: ${r.state}`); + if (r.state === 'deadlocked') console.log(' No eligible successor remains: escalate to an authority (squad lockout override ...) or the user.'); + return 0; + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + return 1; + } +} diff --git a/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts b/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts index eff194639..54f649f3d 100644 --- a/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts +++ b/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts @@ -256,22 +256,90 @@ export function buildCustomAgentCommand( return { cmd: cmd!, args }; } +/** Built-in agent runners for `squad watch`. `copilot` is the default. */ +export const AGENT_PRESETS = ['copilot', 'claude'] as const; +export type AgentPreset = (typeof AGENT_PRESETS)[number]; + +/** + * Build a headless Claude Code invocation. + * + * `session` runs the projected coordinator (`--agent squad`, from `squad init|upgrade --client + * claude`), which treats the "Ralph, Go!" prompt as its Ralph-mode trigger and dispatches members + * as subagents. Without it, the prompt runs in a plain Claude Code session (monitor and hygiene + * prompts). `squad_state` is passed through `context.claudeMcpConfig`, a config this CLI writes with + * only that server, resolved from its own install. The repository's `.mcp.json` is never passed + * explicitly: that would bypass Claude Code's approval for every other server in it. + */ +export function buildClaudeCommand( + prompt: string, + context: Pick, + options: { session?: boolean } = {}, +): { cmd: string; args: string[]; checkStdout: (stdout: string) => string | undefined } { + const args = ['-p', prompt]; + if (options.session) args.push('--agent', 'squad'); + if (context.claudeMcpConfig) { + args.push('--mcp-config', context.claudeMcpConfig); + } else { + console.warn('[squad] ⚠ No squad_state MCP config for the Claude preset: squad_state tools will NOT be available in this session.'); + } + // stream-json (which needs --verbose with -p) ends with a `result` event even when the run + // fails, so a failed session is reported as a failure; see parseClaudeStreamResult. + args.push('--output-format', 'stream-json', '--verbose'); + if (context.claudeFlags) args.push(...context.claudeFlags.trim().split(/\s+/)); + return { cmd: 'claude', args, checkStdout: parseClaudeStreamResult }; +} + +/** Is the Claude Code CLI on PATH? Used by preflight when the Claude preset is selected. */ +export function checkClaudeCli(): Promise<{ ok: true } | { ok: false; reason: string }> { + return new Promise((resolve) => { + execFile('claude', ['--version'], { shell: IS_WINDOWS, timeout: 10_000 }, (err) => { + resolve(err ? { ok: false, reason: "Claude preset: Claude Code CLI ('claude') not found on PATH" } : { ok: true }); + }); + }); +} + +/** + * Read the final `result` event of a Claude Code `stream-json` run. Returns an error message when + * the session reported a failure (Claude Code can exit 0 after, for example, hitting its turn + * limit), or `undefined` when it succeeded or the output has no result event. + */ +export function parseClaudeStreamResult(stdout: string): string | undefined { + const lines = stdout.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]!.trim(); + if (!line.startsWith('{')) continue; + let event: { type?: unknown; is_error?: unknown; subtype?: unknown; result?: unknown }; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (event.type !== 'result') continue; + if (event.is_error !== true && (event.subtype === undefined || event.subtype === 'success')) return undefined; + const detail = typeof event.result === 'string' && event.result.trim() ? event.result.trim() : String(event.subtype ?? 'error'); + return `Claude Code session failed: ${detail.slice(0, 500)}`; + } + return undefined; +} + /** * Build the command + args array for an agent invocation. * * Resolution order: * 1. `context.agentCmd` (explicit override from config / CLI) - * 2. Runtime detection via `resolveCopilotCmd()`: + * 2. `context.agentPreset === 'claude'`: a plain headless Claude Code session + * 3. Runtime detection via `resolveCopilotCmd()`: * - standalone `copilot` if available on PATH * - `gh copilot` as fallback */ export function buildAgentCommand( prompt: string, context: WatchContext, -): { cmd: string; args: string[] } { +): { cmd: string; args: string[]; checkStdout?: (stdout: string) => string | undefined } { if (context.agentCmd) { return buildCustomAgentCommand(context.agentCmd, prompt); } + if (context.agentPreset === 'claude') return buildClaudeCommand(prompt, context); // Default: detect available copilot CLI at runtime (cached) const { cmd, cmdPrefix } = resolveCopilotCmd(); @@ -290,15 +358,17 @@ export function buildAgentCommand( * Unlike {@link buildAgentCommand}, this always defaults to the bare * `copilot` binary rather than probing for a `gh copilot` fallback — used * by capabilities that spawn a full agent session against the repo - * (execute, wave-dispatch). + * (execute, wave-dispatch). With the Claude preset, the session runs the + * projected Squad coordinator instead. */ export function buildCopilotCommand( prompt: string, context: WatchContext, -): { cmd: string; args: string[] } { +): { cmd: string; args: string[]; checkStdout?: (stdout: string) => string | undefined } { if (context.agentCmd) { return buildCustomAgentCommand(context.agentCmd, prompt); } + if (context.agentPreset === 'claude') return buildClaudeCommand(prompt, context, { session: true }); const args = ['-p', prompt]; if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, context.teamRoot) }; @@ -309,13 +379,16 @@ export function buildCopilotCommand( * * Uses `shell: true` on Windows so that `.cmd`/`.bat` wrappers and * PATH resolution work correctly. Args are escaped via `escapeArgs()` - * to prevent Node DEP0190 and cmd.exe metacharacter injection. + * to prevent Node DEP0190 and cmd.exe metacharacter injection. `checkStdout` (from the command + * builder) turns a run that exited 0 but reported a failure, such as a Claude `error_max_turns` + * result, into an error. */ export function spawnWithTimeout( cmd: string, args: string[], cwd: string, timeoutMs: number, + checkStdout?: (stdout: string) => string | undefined, ): Promise { const { args: safeArgs, cleanup } = prepareArgsForSpawn(args); return runProcessTreeCommand(cmd, safeArgs, { @@ -333,6 +406,8 @@ export function spawnWithTimeout( : run.error.message, ); } + const failure = checkStdout?.(run.stdout); + if (failure) throw new Error(failure); }, (error) => { cleanup(); throw error; @@ -599,6 +674,7 @@ export function spawnAgent( pidTracking?: { tracker: NonNullable; label: string }, usageOutputFile?: string, signal?: AbortSignal, + checkStdout?: (stdout: string) => string | undefined, ): Promise { const { args: safeArgs, cleanup } = prepareArgsForSpawn(args); return runProcessTreeCommand(cmd, safeArgs, { @@ -639,6 +715,10 @@ export function spawnAgent( ...(run.error?.signal ? { signal: run.error.signal } : {}), }; } + const reportedError = checkStdout?.(run.stdout); + if (reportedError) { + return { success: false, error: reportedError, usage: usageResult.usage, usageError: usageResult.error }; + } return { success: true, usage: usageResult.usage, diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts index 9dec04cc2..c582df855 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts @@ -54,8 +54,8 @@ export class DecisionHygieneCapability implements WatchCapability { 'git-tracked first (git ls-files --error-unmatch), append and verify before trimming, ' + 'and report entry counts moved rather than file sizes.'; - const { cmd, args } = buildAgentCommand(prompt, context); - await spawnWithTimeout(cmd, args, context.teamRoot, 60_000); + const { cmd, args, checkStdout } = buildAgentCommand(prompt, context); + await spawnWithTimeout(cmd, args, context.teamRoot, 60_000, checkStdout); return { success: true, summary: `decision inbox merged (${fileCount} files)` }; } catch (e) { return { success: false, summary: `decision hygiene: ${(e as Error).message}` }; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts index 8100eddd6..d53eac749 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts @@ -12,6 +12,8 @@ import { createVerboseLogger } from '../verbose.js'; import { loadAgentCharter } from '../../../shell/spawn.js'; import { buildCopilotCommand, + checkClaudeCli, + parseClaudeStreamResult, spawnAgent, withCopilotUsageOutput, } from '../agent-spawn.js'; @@ -217,20 +219,24 @@ async function executeAll( ): Promise { const prompt = buildAgentPrompt(issues, context.teamRoot, context.stateRoot, { claimsActive }); - // Load Ralph's charter to give the spawned session full specialist context. + // The Claude preset runs the projected coordinator, which treats "Ralph, Go!" as its Ralph-mode + // trigger; other runners get Ralph's charter for full specialist context. + const claudeSession = !context.agentCmd && context.agentPreset === 'claude'; let charterPrefix = ''; - try { - const charter = await loadAgentCharter('ralph', context.teamRoot); - charterPrefix = `You are an AI agent on a software development team.\n\nYOUR CHARTER:\n${charter}\n\nADDITIONAL CONTEXT:\n`; - } catch { - // Fall back gracefully if no charter exists (e.g., fresh setup) + if (!claudeSession) { + try { + const charter = await loadAgentCharter('ralph', context.teamRoot); + charterPrefix = `You are an AI agent on a software development team.\n\nYOUR CHARTER:\n${charter}\n\nADDITIONAL CONTEXT:\n`; + } catch { + // Fall back gracefully if no charter exists (e.g., fresh setup) + } } const fullPrompt = charterPrefix + prompt; const { cmd, args: baseArgs } = buildCopilotCommand(fullPrompt, context); let usageCapture: ReturnType | undefined; let usageSetupError: string | undefined; - if (!context.agentCmd) { + if (!context.agentCmd && !claudeSession) { try { usageCapture = withCopilotUsageOutput(baseArgs, context.stateRoot); } catch (error) { @@ -242,7 +248,7 @@ async function executeAll( // Track child PID for cleanup on exit/crash const issueNums = issues.map(i => `#${i.number}`).join(','); const pidTracking = context.pidTracker - ? { tracker: context.pidTracker, label: `copilot-session-${issueNums}` } + ? { tracker: context.pidTracker, label: `${claudeSession ? 'claude' : 'copilot'}-session-${issueNums}` } : undefined; const result = await spawnAgent( @@ -253,6 +259,7 @@ async function executeAll( pidTracking, usageCapture?.filePath, signal, + claudeSession ? parseClaudeStreamResult : undefined, ); return usageSetupError ? { ...result, usageError: usageSetupError } : result; } @@ -291,6 +298,14 @@ async function estimateContextUtilization( } } +/** The Claude preset needs the `claude` CLI and the projected coordinator subagent. */ +export async function claudePreflight(teamRoot: string): Promise { + if (!existsSync(path.join(teamRoot, '.claude', 'agents', 'squad.md'))) { + return { ok: false, reason: 'Claude preset: .claude/agents/squad.md not found. Run `squad upgrade --client claude` first.' }; + } + return checkClaudeCli(); +} + export class ExecuteCapability implements WatchCapability { readonly name = 'execute'; readonly description = 'Spawn Copilot sessions to work on eligible issues'; @@ -298,12 +313,14 @@ export class ExecuteCapability implements WatchCapability { readonly requires = ['gh']; readonly phase = 'post-execute' as const; - async preflight(_context: WatchContext): Promise { - return new Promise((resolve) => { + async preflight(context: WatchContext): Promise { + const gh = await new Promise((resolve) => { execFile('gh', ['--version'], (err) => { resolve(err ? { ok: false, reason: 'gh CLI not found' } : { ok: true }); }); }); + if (!gh.ok || context.agentCmd || context.agentPreset !== 'claude') return gh; + return claudePreflight(context.teamRoot); } async execute(context: WatchContext): Promise { @@ -312,7 +329,7 @@ export class ExecuteCapability implements WatchCapability { try { const timeout = ((context.config['timeout'] as number) ?? 30) * 60_000; - vlog.log(`Execute: agentCmd=${context.agentCmd ?? 'copilot'}, timeout=${timeout / 60_000}m`); + vlog.log(`Execute: agentCmd=${context.agentCmd ?? context.agentPreset ?? 'copilot'}, timeout=${timeout / 60_000}m`); // Fetch open issues with squad label const sdkItems = await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts index 0452f76fb..1da9023ba 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts @@ -4,7 +4,7 @@ import { execFile } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { buildAgentCommand, spawnWithTimeout, IS_WINDOWS } from '../agent-spawn.js'; +import { buildAgentCommand, checkClaudeCli, spawnWithTimeout, IS_WINDOWS } from '../agent-spawn.js'; export class MonitorEmailCapability implements WatchCapability { readonly name = 'monitor-email'; @@ -16,6 +16,7 @@ export class MonitorEmailCapability implements WatchCapability { async preflight(context: WatchContext): Promise { // If using custom agentCmd, skip copilot check if (context.agentCmd) return { ok: true }; + if (context.agentPreset === 'claude') return checkClaudeCli(); return new Promise((resolve) => { execFile('copilot', ['--version'], { shell: IS_WINDOWS, timeout: 5000 }, (err) => { if (err) { @@ -42,8 +43,8 @@ export class MonitorEmailCapability implements WatchCapability { 'If a failed workflow can be re-run, attempt: gh run rerun --failed. ' + 'If WorkIQ is not available, just report that and exit.'; - const { cmd, args } = buildAgentCommand(prompt, context); - await spawnWithTimeout(cmd, args, context.teamRoot, 60_000); + const { cmd, args, checkStdout } = buildAgentCommand(prompt, context); + await spawnWithTimeout(cmd, args, context.teamRoot, 60_000, checkStdout); return { success: true, summary: 'Email scan complete' }; } catch (e) { return { success: false, summary: `Email monitor: ${(e as Error).message}` }; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts index 89dc2da8d..2c14cee20 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts @@ -4,7 +4,7 @@ import { execFile } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { buildAgentCommand, spawnWithTimeout, IS_WINDOWS } from '../agent-spawn.js'; +import { buildAgentCommand, checkClaudeCli, spawnWithTimeout, IS_WINDOWS } from '../agent-spawn.js'; export class MonitorTeamsCapability implements WatchCapability { readonly name = 'monitor-teams'; @@ -16,6 +16,7 @@ export class MonitorTeamsCapability implements WatchCapability { async preflight(context: WatchContext): Promise { // If using custom agentCmd, skip copilot check if (context.agentCmd) return { ok: true }; + if (context.agentPreset === 'claude') return checkClaudeCli(); return new Promise((resolve) => { execFile('copilot', ['--version'], { shell: IS_WINDOWS, timeout: 5000 }, (err) => { if (err) { @@ -41,8 +42,8 @@ export class MonitorTeamsCapability implements WatchCapability { 'First check existing open issues with label "teams-bridge" to avoid duplicates. ' + 'If WorkIQ is not available, just report that and exit.'; - const { cmd, args } = buildAgentCommand(prompt, context); - await spawnWithTimeout(cmd, args, context.teamRoot, 60_000); + const { cmd, args, checkStdout } = buildAgentCommand(prompt, context); + await spawnWithTimeout(cmd, args, context.teamRoot, 60_000, checkStdout); return { success: true, summary: 'Teams scan complete' }; } catch (e) { return { success: false, summary: `Teams monitor: ${(e as Error).message}` }; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts index 0213c4b0d..a1369812a 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts @@ -57,8 +57,8 @@ export class RetroCapability implements WatchCapability { `Summarize: what went well, what didn't, action items. ` + `Write the output to .squad/log/${dateSlug}-retrospective.md`; - const { cmd, args } = buildAgentCommand(prompt, context); - await spawnWithTimeout(cmd, args, context.teamRoot, 120_000); + const { cmd, args, checkStdout } = buildAgentCommand(prompt, context); + await spawnWithTimeout(cmd, args, context.teamRoot, 120_000, checkStdout); return { success: true, summary: 'retrospective completed' }; } catch (e) { return { success: false, summary: `retro: ${(e as Error).message}` }; diff --git a/packages/squad-cli/src/cli/commands/watch/claude-mcp.ts b/packages/squad-cli/src/cli/commands/watch/claude-mcp.ts new file mode 100644 index 000000000..8bb9676cc --- /dev/null +++ b/packages/squad-cli/src/cli/commands/watch/claude-mcp.ts @@ -0,0 +1,27 @@ +/** + * MCP config for the `squad watch` Claude preset (#2442 Phase 6). + * + * Passing the repository's `.mcp.json` with `--mcp-config` would load every server in it without + * Claude Code's project-server approval, under an unattended watcher. Instead, the watcher writes a + * config holding only `squad_state`, resolved by this CLI from its own install exactly as + * `squad init|upgrade` resolve it. Other project servers still go through Claude Code's approval. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec } from '../../core/mcp-spec.js'; +import { getPackageVersion } from '../../core/version.js'; + +/** Write the `squad_state`-only config to a private temp directory; removed when the process exits. */ +export async function writeClaudeWatchMcpConfig(version: string = getPackageVersion()): Promise { + const spec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(version)); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-watch-claude-')); + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync( + file, + `${JSON.stringify({ mcpServers: { squad_state: { command: spec.command, args: [...spec.args], env: {} } } }, null, 2)}\n`, + { mode: 0o600 }, + ); + process.once('exit', () => fs.rmSync(dir, { recursive: true, force: true })); + return file; +} diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index 7a1f7b1b7..abe10b2e2 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -129,6 +129,10 @@ export interface WatchConfig { copilotFlags?: string; /** Hidden — fully override the agent command. */ agentCmd?: string; + /** Built-in runner: `copilot` (default) or `claude`. Ignored when `agentCmd` is set. */ + agentPreset?: 'copilot' | 'claude'; + /** Extra flags for the Claude preset. Execute mode defaults to `--permission-mode bypassPermissions`. */ + claudeFlags?: string; /** Dispatch mode: 'task' (default 1:1), 'fleet' (batch read-only), 'hybrid' (auto-classify). */ dispatchMode?: DispatchMode; /** Optional path to a log file. When set, console output is tee'd to the file with timestamps. */ @@ -155,6 +159,47 @@ export interface WatchConfig { claims?: WatchClaimsConfig; } +/** + * Permission flags the Claude preset uses in execute mode when `claudeFlags` is unset: the + * counterpart of Copilot's `--yolo` default. + */ +export const CLAUDE_DEFAULT_EXECUTE_FLAGS = '--permission-mode bypassPermissions'; + +/** + * Flags `watch.claudeFlags` may set from `.squad/config.json`. That file belongs to the repository, + * so it may tune the model, permission mode, effort and turn limit, but never tool lists (which + * could widen what Claude may run), MCP servers, settings, plugins, extra directories or the agent + * (which would undo the squad_state-only MCP config of the Claude preset). `--claude-flags` on the + * command line is the user's own and is not restricted. + */ +const CLAUDE_FILE_FLAGS: Record boolean> = { + '--permission-mode': (v) => ['default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions'].includes(v), + '--model': (v) => /^[A-Za-z0-9][A-Za-z0-9._:/[\]-]*$/.test(v), + '--fallback-model': (v) => /^[A-Za-z0-9][A-Za-z0-9._:/[\]-]*$/.test(v), + '--effort': (v) => ['low', 'medium', 'high', 'xhigh', 'max'].includes(v), + '--max-turns': (v) => /^[1-9][0-9]{0,4}$/.test(v), +}; + +/** Validate repository-supplied Claude flags; throws {@link WatchConfigError} naming the first bad one. */ +export function validateClaudeFileFlags(flags: string): string { + const tokens = flags.trim().split(/\s+/).filter(Boolean); + for (let i = 0; i < tokens.length; i++) { + const [name, inline] = tokens[i]!.split(/=(.*)/s, 2) as [string, string | undefined]; + const rule = CLAUDE_FILE_FLAGS[name]; + if (!rule) { + throw new WatchConfigError( + `.squad/config.json watch.claudeFlags may not set ${JSON.stringify(name)}. Allowed: ${Object.keys(CLAUDE_FILE_FLAGS).join(', ')}. ` + + 'Pass other flags with --claude-flags on the command line.', + ); + } + const value = inline ?? tokens[++i]; + if (value === undefined || !rule(value)) { + throw new WatchConfigError(`.squad/config.json watch.claudeFlags: invalid value for ${name}: ${JSON.stringify(value ?? '')}`); + } + } + return flags; +} + const DEFAULTS: WatchConfig = { interval: 10, execute: false, @@ -213,6 +258,8 @@ export function loadWatchConfig( timeout: cliOverrides.timeout ?? fileConfig.timeout ?? DEFAULTS.timeout, copilotFlags: cliOverrides.copilotFlags ?? fileConfig.copilotFlags ?? DEFAULTS.copilotFlags, agentCmd: cliOverrides.agentCmd ?? fileConfig.agentCmd ?? DEFAULTS.agentCmd, + agentPreset: cliOverrides.agentPreset ?? fileConfig.agentPreset, + claudeFlags: cliOverrides.claudeFlags ?? fileConfig.claudeFlags, dispatchMode: cliOverrides.dispatchMode ?? fileConfig.dispatchMode ?? DEFAULTS.dispatchMode, logFile: cliOverrides.logFile ?? fileConfig.logFile ?? DEFAULTS.logFile, capabilities: { @@ -236,6 +283,10 @@ export function loadWatchConfig( if (merged.execute && !merged.copilotFlags && !merged.agentCmd) { merged.copilotFlags = '--yolo'; } + // Same for the Claude preset: headless `claude -p` stops at the first tool needing approval. + if (merged.execute && merged.agentPreset === 'claude' && !merged.claudeFlags && !merged.agentCmd) { + merged.claudeFlags = CLAUDE_DEFAULT_EXECUTE_FLAGS; + } return merged; } @@ -258,6 +309,14 @@ function normalizeFileConfig(raw: Record, warn: (message: strin if (typeof raw['timeout'] === 'number') result.timeout = raw['timeout']; if (typeof raw['copilotFlags'] === 'string') result.copilotFlags = raw['copilotFlags']; if (typeof raw['agentCmd'] === 'string') result.agentCmd = raw['agentCmd']; + if ('agentPreset' in raw) { + const preset = raw['agentPreset']; + if (preset !== 'copilot' && preset !== 'claude') { + throw new WatchConfigError(`.squad/config.json watch.agentPreset must be "copilot" or "claude" (got ${JSON.stringify(preset)})`); + } + result.agentPreset = preset; + } + if (typeof raw['claudeFlags'] === 'string') result.claudeFlags = validateClaudeFileFlags(raw['claudeFlags']); if (typeof raw['verbose'] === 'boolean') result.verbose = raw['verbose']; if (typeof raw['dispatchMode'] === 'string') { const mode = raw['dispatchMode'] as string; @@ -295,7 +354,7 @@ function normalizeFileConfig(raw: Record, warn: (message: strin // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'maxRounds', 'maxDuration', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile', 'authUser', 'notifyLevel', 'overnightStart', 'overnightEnd', 'sentinelFile', 'stateBackend', 'claims']); + const reserved = new Set(['interval', 'maxRounds', 'maxDuration', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'agentPreset', 'claudeFlags', 'verbose', 'dispatchMode', 'logFile', 'authUser', 'notifyLevel', 'overnightStart', 'overnightEnd', 'sentinelFile', 'stateBackend', 'claims']); for (const [key, value] of Object.entries(raw)) { if (reserved.has(key)) continue; if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) { diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index ca9567ba2..bbbf6a948 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -42,6 +42,7 @@ import { createVerboseLogger, type VerboseLogger } from './verbose.js'; import { flattenClaimCounters, formatClaimRoundLines, hasClaimActivity, type ClaimCounters } from './claim-counters.js'; import { updatePidFileClaims } from './health.js'; import { buildCustomAgentCommand } from './agent-spawn.js'; +import { writeClaudeWatchMcpConfig } from './claude-mcp.js'; const storage = new FSStorageProvider(); const execFileAsync = promisify(execFile); @@ -782,7 +783,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig verbose: config.verbose ?? false, interval: `${config.interval}m`, execute: config.execute ?? false, - agentCmd: config.agentCmd ?? '(default: copilot)', + agentCmd: config.agentCmd ?? `(preset: ${config.agentPreset ?? 'copilot'})`, dispatchMode: config.capabilities['wave-dispatch'] ? 'wave' : 'task', maxConcurrent: config.maxConcurrent ?? 1, }); @@ -925,6 +926,8 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig })}${RESET}`); vlog.log(`Claim holder: ${executor.machineId}/${executor.instanceId}; work repo: ${workRepo ?? '(unknown)'}`); + // The Claude preset gets squad_state from this CLI, never the repository's whole .mcp.json. + const claudeMcpConfig = !config.agentCmd && config.agentPreset === 'claude' ? await writeClaudeWatchMcpConfig() : undefined; const baseContext: WatchContext = { teamRoot, stateRoot: stateDir, @@ -933,7 +936,10 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig roster: roster.map(r => ({ name: r.name, label: r.label, expertise: [] as string[] })), config: {}, agentCmd: config.agentCmd, + ...(config.agentPreset ? { agentPreset: config.agentPreset } : {}), copilotFlags: config.copilotFlags, + ...(config.claudeFlags ? { claudeFlags: config.claudeFlags } : {}), + ...(claudeMcpConfig ? { claudeMcpConfig } : {}), verbose: config.verbose, pidTracker, claims: claimProvider, @@ -949,7 +955,9 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig const platformTag = ` [${adapter.type}]`; console.log(`\n${BOLD}🔄 Ralph — Watch Mode${RESET}${modeTag}${platformTag}`); console.log(`${DIM}Polling every ${interval} minute(s) for squad work. Ctrl+C to stop.${RESET}`); - if (config.execute && config.copilotFlags) { + if (config.execute && !config.agentCmd && config.agentPreset === 'claude') { + console.log(`${DIM}Runner: Claude Code (claude -p --agent squad)${config.claudeFlags ? ` | flags: ${config.claudeFlags}` : ''}${RESET}`); + } else if (config.execute && config.copilotFlags) { console.log(`${DIM}Copilot flags: ${config.copilotFlags}${RESET}`); } if (config.execute) { diff --git a/packages/squad-cli/src/cli/commands/watch/types.ts b/packages/squad-cli/src/cli/commands/watch/types.ts index a79e1438c..301fb5788 100644 --- a/packages/squad-cli/src/cli/commands/watch/types.ts +++ b/packages/squad-cli/src/cli/commands/watch/types.ts @@ -45,7 +45,16 @@ export interface WatchContext { config: Record; /** Hidden --agent-cmd override. */ agentCmd?: string; + /** Built-in runner: `copilot` (default) or `claude`. `agentCmd` takes precedence. */ + agentPreset?: 'copilot' | 'claude'; copilotFlags?: string; + /** Extra flags for the Claude preset (execute mode defaults to `--permission-mode bypassPermissions`). */ + claudeFlags?: string; + /** + * MCP config for the Claude preset holding only `squad_state`, resolved by this CLI from its own + * install (not the repository's `.mcp.json`, whose other servers keep Claude Code's approval). + */ + claudeMcpConfig?: string; /** Verbose diagnostic output enabled. */ verbose?: boolean; /** PID tracker for child process cleanup (optional — only set when watch is running). */ diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index e991f815c..042d86222 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -168,6 +168,7 @@ export async function syncClients( export function reportClientResults(results: readonly ApplyResult[], log: (m: string) => void = console.log): void { for (const r of results) { if (r.action === 'error') log(`⚠ ${r.path}: ${r.error}`); + else if (r.action === 'skipped') log(`⚠ skipped ${r.path}: ${r.error}`); else if (r.action !== 'unchanged') log(`✓ ${r.action} ${r.path}`); } } diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index 1353211ff..96449938e 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -394,6 +394,8 @@ function printWatchHelp(name: 'triage' | 'watch', version: string): void { console.log(`Core flags:`); console.log(` ${BOLD}--execute${RESET} Spawn agents to work on issues`); console.log(` ${BOLD}--copilot-flags "..."${RESET} Extra flags for Copilot CLI`); + console.log(` ${BOLD}--agent-preset ${RESET} Agent runner: copilot (default) or claude`); + console.log(` ${BOLD}--claude-flags "..."${RESET} Extra flags for the claude preset`); console.log(` ${BOLD}--max-concurrent N${RESET} Parallel issue limit (default 1)`); console.log(` ${BOLD}--timeout N${RESET} Max minutes per issue (default 30)`); console.log(` ${BOLD}--claims ${RESET} Claim issues across watchers (default none)`); diff --git a/packages/squad-sdk/src/client/adapters/adapters.ts b/packages/squad-sdk/src/client/adapters/adapters.ts index 7870ea450..1b65f001b 100644 --- a/packages/squad-sdk/src/client/adapters/adapters.ts +++ b/packages/squad-sdk/src/client/adapters/adapters.ts @@ -9,6 +9,9 @@ import path from 'node:path'; import { CAPABILITIES } from './capabilities.js'; import { upsertTomlTable } from './apply.js'; import { buildCoordinatorInstructions, dispatchStrategy } from './coordinator.js'; +import { projectClaudeMembers } from './claude-members.js'; +import { projectClaudeSkills } from './claude-skills.js'; +import { projectClaudeHooks } from './claude-hooks.js'; import type { HostProjection, ClientContext, ClientId, ProjectionFile } from './types.js'; import { CLIENT_IDS } from './types.js'; @@ -18,6 +21,15 @@ const DESCRIPTION = const GENERATED_NOTE_TEXT = 'Generated by Squad. Managed file: refreshed by `squad upgrade`; edit .squad/ instead.'; const GENERATED_NOTE = ``; +/** + * Claude Code projection version (harness-adapters §5). v1: coordinator, `.mcp.json`, `CLAUDE.md` + * block, `.gitignore` lines. v2: adds member subagents, skills and the opt-in `settings.json` hooks. + * Bump once per change to the set or shape of projected files. + */ +export const CLAUDE_PROJECTION_VERSION = 2; +/** Claude files carry the version; the shared note text stays a substring so v1 files still match as Squad-owned. */ +const CLAUDE_GENERATED_NOTE = ``; + type Json = Record; function isObject(v: unknown): v is Json { @@ -104,7 +116,7 @@ const claude: HostProjection = { ['name', 'squad'], ['description', yamlString(DESCRIPTION)], ]) + - `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('claude', ctx.squadDir, ctx.root)}`, + `${CLAUDE_GENERATED_NOTE}\n\n${buildCoordinatorInstructions('claude', ctx.squadDir, ctx.root)}`, }, mcpJson(ctx, false), { @@ -114,6 +126,12 @@ const claude: HostProjection = { }, // Claude Code creates embedded git worktrees here for isolated subagents. { kind: 'lines', path: '.gitignore', id: 'claude', lines: ['.claude/worktrees/'] }, + // One subagent per member charter; user-authored files at the same path are never overwritten. + ...projectClaudeMembers(ctx, CLAUDE_GENERATED_NOTE, GENERATED_NOTE_TEXT), + // Template skills plus the coordinator's on-demand sections, as native Claude Code skills. + ...projectClaudeSkills(ctx, CLAUDE_GENERATED_NOTE, GENERATED_NOTE_TEXT), + // Opt-in advisory governance hooks (`claudeHooks` in config.json), merged beside user hooks. + ...projectClaudeHooks(ctx), ], }; diff --git a/packages/squad-sdk/src/client/adapters/apply.ts b/packages/squad-sdk/src/client/adapters/apply.ts index 61d10083e..cea7e096c 100644 --- a/packages/squad-sdk/src/client/adapters/apply.ts +++ b/packages/squad-sdk/src/client/adapters/apply.ts @@ -33,8 +33,11 @@ export function upsertBlock(existing: string, content: string, id?: string): str if (start !== -1 && end > start) { return existing.slice(0, start) + block + existing.slice(end + endMarker.length); } - const sep = existing.length === 0 ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; - return `${existing}${sep}${block}\n`; + // One blank line before the block, and a final newline only if the file had one, so + // removeBlock can undo exactly this and restore the original bytes (§15.7). + if (existing.length === 0) return `${block}\n`; + if (existing.endsWith('\n')) return `${existing}\n${block}\n`; + return `${existing}\n\n${block}`; } const linesMarker = (id: string) => `# squad:${id}`; @@ -673,15 +676,21 @@ export function applyProjection(root: string, files: readonly ProjectionFile[]): transforms.get(rel)!.push(fn); }; + const owned: Array> = []; for (const f of files) { - if (f.kind === 'managed') add(f.path, () => f.content); + if (f.kind === 'obsolete' || (f.kind === 'managed' && f.ownedMarker !== undefined)) owned.push(f); + else if (f.kind === 'managed') add(f.path, () => f.content); else if (f.kind === 'block') add(f.path, (t) => upsertBlock(t ?? '', f.content, f.id)); else if (f.kind === 'lines') add(f.path, (t) => upsertLines(t ?? '', f.lines, f.id)); else if (f.kind === 'text') add(f.path, (t) => f.update(t ?? '')); else { add(f.path, (t, abs) => { assertMergeableJson(abs, t); - return stringifyJson(f.update(parseJsonObject(abs, t)), t); + const current = parseJsonObject(abs, t); + const next = f.update(current); + // Nothing to change: keep the user's bytes (an existing `{}` is not deleted, nor reformatted). + if (t !== undefined && JSON.stringify(next) === JSON.stringify(current)) return t; + return Object.keys(next).length === 0 ? '' : stringifyJson(next, t); }); } } @@ -693,19 +702,49 @@ export function applyProjection(root: string, files: readonly ProjectionFile[]): const eol = detectEol(original); let text = original === undefined ? undefined : toLf(original); for (const fn of transforms.get(rel)!) text = fn(text, abs); + // Nothing left to write (e.g. a JSON config with only Squad's keys removed): never create an + // empty file, and remove one that would be left empty. + if ((text ?? '').trim() === '') { + if (original === undefined) continue; + fs.rmSync(abs); + pruneEmptyDirs(root, rel); + results.push({ path: rel, action: 'removed' }); + continue; + } results.push({ path: rel, action: writeIfChanged(abs, withEol(toLf(text ?? ''), eol)) }); } catch (err) { results.push({ path: rel, action: 'error', error: err instanceof Error ? err.message : String(err) }); } } + for (const f of owned) { + try { + const abs = resolveInsideRoot(root, f.path); + const original = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : undefined; + if (original !== undefined && !original.includes(f.ownedMarker!)) { + results.push({ path: f.path, action: 'skipped', error: USER_AUTHORED }); + } else if (f.kind === 'obsolete') { + if (original === undefined) continue; + fs.rmSync(abs); + pruneEmptyDirs(root, f.path); + results.push({ path: f.path, action: 'removed' }); + } else { + results.push({ path: f.path, action: writeIfChanged(abs, withEol(toLf(f.content), detectEol(original))) }); + } + } catch (err) { + results.push({ path: f.path, action: 'error', error: err instanceof Error ? err.message : String(err) }); + } + } return results; } + +const USER_AUTHORED = 'not generated by Squad (user-authored); left unchanged'; + /** Project and apply the given clients in one step. */ export function installClients(clients: readonly ClientId[], ctx: ClientContext): ApplyResult[] { return applyProjection(ctx.root, projectClients(clients, ctx)); } -/** Remove the Squad-managed block; collapses the separator that `upsertBlock` added. */ +/** Remove the Squad-managed block and exactly the separator `upsertBlock` added. */ export function removeBlock(existing: string, id?: string): string { const { begin, end: endMarker } = markers(id); const start = existing.indexOf(begin); @@ -714,9 +753,12 @@ export function removeBlock(existing: string, id?: string): string { throw new Error('Unclosed Squad managed block marker in file; refusing to overwrite user content after marker'); } if (start === -1) return existing; - const before = existing.slice(0, start).replace(/\n{2,}$/, '\n'); - const after = existing.slice(end + endMarker.length).replace(/^\n/, ''); - return before + after; + const before = existing.slice(0, start); + const after = existing.slice(end + endMarker.length); + if (before === '') return after.replace(/^\n/, ''); + // Block appended to a file without a final newline: drop the blank-line separator only. + if (after === '') return before.replace(/\n\n$/, ''); + return before.replace(/\n$/, '') + after.replace(/^\n/, ''); } /** Remove a whole `[table]`, its `[table.*]` sub-tables, and any inline/dotted definitions; other tables are untouched. */ @@ -814,14 +856,19 @@ export function removeProjection(root: string, files: readonly ProjectionFile[]) results.push({ path: f.path, action: 'unchanged' }); continue; } - if (f.kind === 'managed') { + if (f.kind === 'managed' || f.kind === 'obsolete') { + const marker = f.ownedMarker; + if (marker !== undefined && !fs.readFileSync(abs, 'utf8').includes(marker)) { + results.push({ path: f.path, action: 'skipped', error: USER_AUTHORED }); + continue; + } fs.rmSync(abs); results.push({ path: f.path, action: 'removed' }); } else if (f.kind === 'json') { const raw = fs.readFileSync(abs, 'utf8'); assertMergeableJson(abs, raw); const current = parseJsonObject(abs, raw); - const next = removeServerEntry(current); + const next = (f.remove ?? removeServerEntry)(current); if (JSON.stringify(next) === JSON.stringify(current)) { results.push({ path: f.path, action: 'unchanged' }); continue; diff --git a/packages/squad-sdk/src/client/adapters/claude-hooks.ts b/packages/squad-sdk/src/client/adapters/claude-hooks.ts new file mode 100644 index 000000000..ca08c69e0 --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/claude-hooks.ts @@ -0,0 +1,414 @@ +/** + * Claude Code governance hooks (#2438 Phase 3): opt-in, advisory only. + * + * With `"claudeHooks": "advisory"` in `/config.json`, the Claude projection merges a + * Squad-owned set of hook handlers into `.claude/settings.json`. Each handler runs + * ` hook claude `, the same launcher as the `squad_state` MCP server. The + * hooks warn and add context; they never deny a tool call. User hooks in the same file are never + * touched: Squad's handlers are recognised by their `hook claude ` argument tail. + * + * @module client/adapters/claude-hooks + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseCharterMarkdown } from '../../agents/charter-compiler.js'; +import { CLAUDE_AGENTS_DIR, readRouting } from './claude-members.js'; +import { effectiveLocked, lockoutsFor, readLockouts, type LockoutRecord } from './lockout.js'; +import type { ClientContext, McpLaunchSpec, ProjectionFile } from './types.js'; +import { readContained } from './safe-path.js'; + +export const CLAUDE_SETTINGS_PATH = '.claude/settings.json'; + +export const CLAUDE_HOOK_EVENTS = ['SessionStart', 'SubagentStart', 'PreToolUse', 'PostToolUse', 'Stop'] as const; +export type ClaudeHookEvent = (typeof CLAUDE_HOOK_EVENTS)[number]; + +export type ClaudeHooksMode = 'off' | 'advisory'; + +/** Matcher per event (Claude Code exact-match lists); `undefined` matches every occurrence. */ +const MATCHERS: Record = { + SessionStart: undefined, + SubagentStart: 'squad', + PreToolUse: 'Edit|MultiEdit|Write|NotebookEdit', + PostToolUse: 'Agent|Task', + Stop: undefined, +}; + +const HOOK_TIMEOUT_SECONDS = 15; +const COORDINATOR = 'squad'; +/** Claude Code caps hook context at 10,000 characters; stay well under it. */ +const MAX_CONTEXT = 8000; + +type Json = Record; + +function isObject(v: unknown): v is Json { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** `claudeHooks` from `/config.json`. Anything but `"advisory"` is off. */ +export function readClaudeHooksMode(squadAbs: string): ClaudeHooksMode { + try { + const cfg = JSON.parse(fs.readFileSync(path.join(squadAbs, 'config.json'), 'utf8')) as { claudeHooks?: unknown }; + return cfg.claudeHooks === 'advisory' ? 'advisory' : 'off'; + } catch { + return 'off'; + } +} + +/** + * The hook launch command: the MCP launcher with its trailing `state-mcp` replaced. + * + * No command allowlist on purpose. `mcp` is resolved by the running CLI from its own install + * (standalone launcher, `node /cli-entry.js`, or `npx` with the exact package version), never + * from repository content, and the same command is already projected into `.mcp.json` as the + * `squad_state` server. Hooks therefore add no new executable. + */ +export function claudeHookLaunch(mcp: McpLaunchSpec, event: ClaudeHookEvent): McpLaunchSpec | null { + if (mcp.args[mcp.args.length - 1] !== 'state-mcp') return null; + return { command: mcp.command, args: [...mcp.args.slice(0, -1), 'hook', 'claude', event] }; +} + +/** A handler Squad wrote: a command hook whose arguments end in `hook claude `. */ +export function isSquadHookHandler(h: unknown): boolean { + if (!isObject(h) || h['type'] !== 'command' || !Array.isArray(h['args'])) return false; + const a = h['args'] as unknown[]; + return ( + a.length >= 3 && + a[a.length - 3] === 'hook' && + a[a.length - 2] === 'claude' && + (CLAUDE_HOOK_EVENTS as readonly unknown[]).includes(a[a.length - 1]) + ); +} + +/** Remove Squad's handlers (and groups and events they leave empty). Untouched input is returned as is. */ +export function stripClaudeHooks(settings: Json): Json { + const hooks = settings['hooks']; + if (!isObject(hooks)) return settings; + let changed = false; + const next: Json = {}; + for (const [event, groups] of Object.entries(hooks)) { + if (!Array.isArray(groups)) { + next[event] = groups; + continue; + } + const kept: unknown[] = []; + let removedHere = false; + for (const g of groups) { + if (!isObject(g) || !Array.isArray(g['hooks'])) { + kept.push(g); + continue; + } + const handlers = (g['hooks'] as unknown[]).filter((h) => !isSquadHookHandler(h)); + if (handlers.length === (g['hooks'] as unknown[]).length) kept.push(g); + else { + removedHere = true; + if (handlers.length > 0) kept.push({ ...g, hooks: handlers }); + } + } + if (removedHere) changed = true; + if (!removedHere || kept.length > 0) next[event] = removedHere ? kept : groups; + } + if (!changed) return settings; + const out: Json = { ...settings }; + if (Object.keys(next).length === 0) delete out['hooks']; + else out['hooks'] = next; + return out; +} + +/** Refresh Squad's handlers: strip the old ones, then append one group per event when `mcp` is given. */ +export function mergeClaudeHooks(settings: Json, mcp: McpLaunchSpec | null): Json { + const stripped = stripClaudeHooks(settings); + if (!mcp) return stripped; + if (stripped['hooks'] !== undefined && !isObject(stripped['hooks'])) { + throw new Error('Refusing to overwrite "hooks" in .claude/settings.json: expected a JSON object'); + } + const hooks: Json = { ...((stripped['hooks'] as Json | undefined) ?? {}) }; + for (const event of CLAUDE_HOOK_EVENTS) { + const launch = claudeHookLaunch(mcp, event); + if (!launch) return stripped; + const groups = hooks[event] ?? []; + if (!Array.isArray(groups)) throw new Error(`Refusing to overwrite "hooks.${event}" in .claude/settings.json: expected an array`); + const handler = { type: 'command', command: launch.command, args: launch.args, timeout: HOOK_TIMEOUT_SECONDS }; + const matcher = MATCHERS[event]; + hooks[event] = [...groups, matcher ? { matcher, hooks: [handler] } : { hooks: [handler] }]; + } + return { ...stripped, hooks }; +} + +/** + * The `.claude/settings.json` entry. Emitted while hooks are on, and while the file exists (so + * turning hooks off, or rolling Claude back, removes Squad's handlers). Never creates the file + * just to leave it empty. + */ +export function projectClaudeHooks(ctx: ClientContext): ProjectionFile[] { + const squadAbs = path.resolve(ctx.root, ctx.squadDir ?? '.squad'); + const on = readClaudeHooksMode(squadAbs) === 'advisory' && claudeHookLaunch(ctx.mcp, 'Stop') !== null; + if (!on && !fs.existsSync(path.join(ctx.root, CLAUDE_SETTINGS_PATH))) return []; + return [ + { + kind: 'json', + path: CLAUDE_SETTINGS_PATH, + update: (existing) => mergeClaudeHooks(existing, on ? ctx.mcp : null), + remove: stripClaudeHooks, + }, + ]; +} + +// --------------------------------------------------------------------------------------------- +// Hook runtime: `squad hook claude ` reads the event JSON on stdin and prints advisory output. +// --------------------------------------------------------------------------------------------- + +export interface ClaudeHookEnv { + /** Repository root (Claude Code's `CLAUDE_PROJECT_DIR`). */ + root: string; + /** Team squad directory (team.md, agents/, config.json). */ + teamAbs: string; + /** Project-local squad directory (decisions inbox). Equals `teamAbs` in local mode. */ + projectAbs: string; + /** Squad CLI version, for the health line. */ + version: string; +} + +/** Claude Code hook output (the subset Squad uses). `null`: print nothing. */ +export type ClaudeHookOutput = { + systemMessage?: string; + hookSpecificOutput?: { hookEventName: ClaudeHookEvent; additionalContext: string }; +} | null; + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * Real path of `abs`, resolved through its nearest existing ancestor (a Write target may not exist + * yet). Claude Code and the resolved team root can disagree on symlinks, such as macOS `/var`. + */ +function realish(abs: string): string { + let dir = abs; + const rest: string[] = []; + for (;;) { + try { + return path.join(fs.realpathSync(dir), ...rest.reverse()); + } catch { + const parent = path.dirname(dir); + if (parent === dir) return abs; + rest.push(path.basename(dir)); + dir = parent; + } + } +} + +const MEMBER_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function cap(s: string): string { + return s.length > MAX_CONTEXT ? `${s.slice(0, MAX_CONTEXT - 1)}…` : s; +} + +/** Member IDs (directories under `agents/` with a charter); names outside `a-z0-9-` are ignored. */ +function memberIdsWithCharters(teamAbs: string): string[] { + try { + return fs + .readdirSync(path.join(teamAbs, 'agents'), { withFileTypes: true }) + .filter((e) => e.isDirectory() && MEMBER_ID_RE.test(e.name) && fs.existsSync(path.join(teamAbs, 'agents', e.name, 'charter.md'))) + .map((e) => e.name) + .sort(); + } catch { + return []; + } +} + +/** + * Lockout fields come from a file in the repository and from free text typed with `squad lockout`, + * so they reach the coordinator as inert data: one line, no markup or code delimiters, bounded. + */ +function inert(value: unknown, max = 120): string { + const flat = String(value ?? '') + .replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ') + .replace(/[`<>{}[\]]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +/** Member IDs only (`a-z0-9-`); anything else in the file is dropped. */ +function ids(values: readonly unknown[]): string[] { + return values.filter((v): v is string => typeof v === 'string' && MEMBER_ID_RE.test(v)); +} + +function describeLockout(r: LockoutRecord): string { + const scope = r.paths.length > 0 ? ` on ${r.paths.slice(0, 10).map((p) => inert(p, 80)).join(', ')}` : ''; + const who = ids(effectiveLocked(r)).join(', '); + const [reviewer] = ids([r.reviewer]); + const [successor] = ids([r.successor]); + const next = successor ? `; revision assigned to ${successor}` : ''; + // The reason is quoted as data (JSON string), so it reads as the reviewer's words, not as instructions. + const reason = r.reason ? ` (reason, as recorded: ${JSON.stringify(inert(r.reason, 200))})` : ''; + return `${inert(r.subject, 80)} (${inert(r.state, 24)})${scope}: ${who} locked out${reviewer ? ` after ${reviewer}'s REJECT` : ''}${reason}${next}`; +} + +function activeLockouts(teamAbs: string): LockoutRecord[] { + try { + return readLockouts(teamAbs).subjects.filter((r) => effectiveLocked(r).length > 0); + } catch { + return []; + } +} + +/** `open` Ralph circuit breaker still in cooldown (templates/ralph-circuit-breaker.md). */ +function openCircuit(projectAbs: string, now: number): string | undefined { + try { + const cb = JSON.parse(fs.readFileSync(path.join(projectAbs, 'ralph-circuit-breaker.json'), 'utf8')) as Json; + if (cb['state'] !== 'open') return undefined; + const opened = Date.parse(String(cb['openedAt'] ?? '')); + const minutes = typeof cb['cooldownMinutes'] === 'number' ? (cb['cooldownMinutes'] as number) : 10; + if (Number.isFinite(opened) && now - opened >= minutes * 60_000) return undefined; + const chain = Array.isArray(cb['fallbackChain']) ? (cb['fallbackChain'] as unknown[]).map(String) : []; + const idx = typeof cb['currentFallbackIndex'] === 'number' ? (cb['currentFallbackIndex'] as number) : 0; + const fallback = chain[Math.min(idx, chain.length - 1)]; + const until = Number.isFinite(opened) ? ` until ${new Date(opened + minutes * 60_000).toISOString()}` : ''; + // Model names come from a repository file: inert, as for lockout fields. + const preferred = inert(cb['preferredModel'] ?? 'the preferred model', 60); + return `Ralph circuit breaker is OPEN${until}: spawn with ${fallback ? `the fallback model ${JSON.stringify(inert(fallback, 60))}` : 'a fallback model'} instead of ${JSON.stringify(preferred)}.`; + } catch { + return undefined; + } +} + +function inboxCount(projectAbs: string): number { + try { + return fs.readdirSync(path.join(projectAbs, 'decisions', 'inbox')).filter((f) => f.endsWith('.md')).length; + } catch { + return 0; + } +} + +function sessionContext(env: ClaudeHookEnv, now: number): { additionalContext?: string; systemMessage?: string } { + const squadDir = path.relative(realish(env.root), realish(env.teamAbs)).split(path.sep).join('/') || '.'; + if (!fs.existsSync(path.join(env.teamAbs, 'team.md'))) { + return { systemMessage: `Squad: no team found at ${squadDir}/team.md. Run \`squad init\` to cast a team.` }; + } + const lines = [ + `Squad session context (advisory hooks, squad CLI ${env.version}). Team root: \`${squadDir}\`. ` + + `The squad_state bridge must report \`squad-state ${env.version}\` and this team dir in \`squad_state_health\`.`, + ]; + const members = memberIdsWithCharters(env.teamAbs).map((id) => { + try { + const c = parseCharterMarkdown(readContained(env.teamAbs, path.join('agents', id, 'charter.md'))); + const sub = fs.existsSync(path.join(env.root, CLAUDE_AGENTS_DIR, `${id}.md`)) ? '' : ' (no subagent file)'; + const role = c.identity.role ? inert(c.identity.role, 60) : ''; + return `${id}${role ? ` (${role})` : ''}${sub}`; + } catch { + return id; + } + }); + if (members.length > 0) lines.push(`Roster: ${members.join(', ')}.`); + const routing = readRouting(env.teamAbs); + // routing.md is repository text: each work type is quoted as data, and only roster members route. + const known = new Set(memberIdsWithCharters(env.teamAbs)); + const routes = routing + .slice(0, 40) + .map((r) => ({ + work: inert(r.workType, 80), + to: [...new Set(ids(r.routeTo.toLowerCase().split(/[\s,/&+]+/).map((t) => t.replace(/^@/, ''))).filter((id) => known.has(id)))], + })) + .filter((r) => r.work && r.to.length > 0); + if (routes.length > 0) lines.push(`Routing (work type, as written → member): ${routes.map((r) => `${JSON.stringify(r.work)} → ${r.to.join(', ')}`).join('; ')}.`); + const locks = activeLockouts(env.teamAbs); + if (locks.length > 0) lines.push(`Active reviewer lockouts (governance-review §6): ${locks.map(describeLockout).join('; ')}.`); + const circuit = openCircuit(env.projectAbs, now); + if (circuit) lines.push(circuit); + return { additionalContext: cap(lines.join('\n')) }; +} + +/** Run one hook event. Advisory: the result only adds context or a warning, never a permission decision. */ +export function runClaudeHook(event: string, input: Json, env: ClaudeHookEnv, now = Date.now()): ClaudeHookOutput { + if (readClaudeHooksMode(env.teamAbs) !== 'advisory') return null; + const agent = str(input['agent_type'])?.toLowerCase(); + + if (event === 'SessionStart' || event === 'SubagentStart') { + if (agent !== COORDINATOR) return null; + const ctx = sessionContext(env, now); + if (!ctx.additionalContext) return { systemMessage: ctx.systemMessage }; + return { hookSpecificOutput: { hookEventName: event, additionalContext: ctx.additionalContext } }; + } + + if (event === 'PreToolUse') { + if (!agent || agent === COORDINATOR) return null; + const toolInput = isObject(input['tool_input']) ? input['tool_input'] : {}; + const file = str(toolInput['file_path']) ?? str(toolInput['notebook_path']); + if (!file) return null; + const target = realish(path.resolve(str(input['cwd']) ?? env.root, file)); + const rel = path.relative(realish(env.root), target).split(path.sep).join('/'); + if (!rel || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) return null; + let hits: LockoutRecord[]; + try { + hits = lockoutsFor(readLockouts(env.teamAbs), agent, rel); + } catch { + return null; + } + if (hits.length === 0) return null; + const subjects = hits.map(describeLockout).join('; '); + const next = ids(hits.map((h) => h.successor)); + const warning = + `Squad reviewer lockout (advisory): ${agent} is locked out of ${subjects}. ` + + `Under governance-review §6 a locked-out author may not revise the rejected work. ` + + `Stop editing \`${rel}\` and hand back to the coordinator${next.length > 0 ? `; the revision belongs to ${[...new Set(next)].join(', ')}` : ' to reassign the revision'}.`; + return { systemMessage: warning, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: warning } }; + } + + if (event === 'PostToolUse') { + if (agent && agent !== COORDINATOR) return null; + const toolInput = isObject(input['tool_input']) ? input['tool_input'] : {}; + const member = str(toolInput['subagent_type'])?.toLowerCase(); + if (!member || !memberIdsWithCharters(env.teamAbs).includes(member)) return null; + // Structured fields only: free text from the tool call (its `description`) is not reflected + // back into the coordinator's context, so the hook cannot become an instruction channel. + // A background Agent call returns at launch (`status: "async_launched"`), before the member has + // finished. Claude Code gives no parent-side hook for the later completion (SubagentStop context + // goes to the subagent), so a launch is reported as an acknowledgement, never as a result. + const response = isObject(input['tool_response']) ? input['tool_response'] : {}; + const launched = response['status'] === 'async_launched'; + const envelope = { + profile: 'squad-coordination-handoff/v0.1', + type: launched ? 'acknowledgement' : 'result', + messageId: str(input['tool_use_id']) ?? null, + correlationId: str(input['session_id']) ?? null, + sender: member, + recipient: COORDINATOR, + createdAt: new Date(now).toISOString(), + }; + const locks = activeLockouts(env.teamAbs).filter((r) => r.successor === member || effectiveLocked(r).includes(member)); + const review = + locks.length > 0 ? ` Lockout state for this member: ${locks.map(describeLockout).join('; ')}.` : ''; + return { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: cap( + launched + ? `Squad handoff acknowledgement: ${JSON.stringify(envelope)}. ${member} is running in the background and has not finished; ` + + `wait for its completion notification, then record any verdict with \`squad_decide\` and run the reviewer gate.${review}` + : `Squad handoff result envelope: ${JSON.stringify(envelope)}. Record any verdict or decision with \`squad_decide\`, ` + + `and run the reviewer gate before declaring the work done.${review}`, + ), + }, + }; + } + + if (event === 'Stop') { + if (agent !== COORDINATOR || input['stop_hook_active'] === true) return null; + const circuit = openCircuit(env.projectAbs, now); + const inbox = inboxCount(env.projectAbs); + if (inbox === 0) return circuit ? { systemMessage: circuit } : null; + return { + ...(circuit ? { systemMessage: circuit } : {}), + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: + `The decisions inbox has ${inbox} unmerged ${inbox === 1 ? 'entry' : 'entries'}. ` + + 'Spawn Scribe (background) to merge it into decisions.md per the Scribe spawn protocol, then finish.', + }, + }; + } + + return null; +} diff --git a/packages/squad-sdk/src/client/adapters/claude-members.ts b/packages/squad-sdk/src/client/adapters/claude-members.ts new file mode 100644 index 000000000..baa6b5d26 --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/claude-members.ts @@ -0,0 +1,238 @@ +/** + * Claude Code member subagents: one `.claude/agents/.md` per Squad team member. + * + * Charter v0.1 §6 and Execution Preferences v0.1 §2 map onto Claude Code subagent frontmatter. + * Host-only settings come from the charter extension `## X-squad-host-claude` (charter §8). They + * are runtime configuration, not authorization (charter §13.1). + * + * @module client/adapters/claude-members + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseCharterMarkdown } from '../../agents/charter-compiler.js'; +import { VALID_REASONING_EFFORTS } from '../../config/models.js'; +import type { ClientContext, ProjectionFile } from './types.js'; +import { readContained } from './safe-path.js'; + +/** Charter extension heading (charter §8: namespace `squad`, name `host-claude`). */ +export const CLAUDE_HOST_EXTENSION = 'X-squad-host-claude'; + +export const CLAUDE_AGENTS_DIR = '.claude/agents'; + +/** File names in {@link CLAUDE_AGENTS_DIR} that are never member subagents. */ +const RESERVED_IDS = new Set(['squad']); + +const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** Claude Code model aliases or `claude-*` model IDs, optionally with the 1M-context suffix. */ +const CLAUDE_MODEL_RE = /^(?:sonnet|opus|haiku|fable|inherit|claude-[a-z0-9][a-z0-9.-]*)(\[1m\])?$/i; +/** Tool names and permission-style patterns; anything else could break out of the frontmatter. */ +const TOOL_RE = /^[A-Za-z0-9_*.:-]+(?:\([^\r\n()]*\))?$/; +const MAX_DESCRIPTION = 400; + +export interface ClaudeHostSettings { + tools?: string[]; + isolation?: 'worktree'; + /** Authored values that could not be projected, for the generated file's notes. */ + rejected: string[]; +} + +/** Parse `## X-squad-host-claude` fields (`- **Label:** value`) from a charter. Unknown labels are ignored. */ +export function parseClaudeHostSettings(charter: string): ClaudeHostSettings { + const out: ClaudeHostSettings = { rejected: [] }; + const lines = charter.replace(/\r\n?/g, '\n').split('\n'); + const start = lines.findIndex((l) => new RegExp(`^##\\s+${CLAUDE_HOST_EXTENSION}\\s*$`, 'i').test(l)); + if (start === -1) return out; + for (const line of lines.slice(start + 1)) { + if (/^#{1,2}\s/.test(line)) break; + const m = line.match(/^\s*[-*]\s+\*\*([^*]+?):?\*\*:?\s*(.*)$/); + if (!m) continue; + const label = m[1]!.trim().toLowerCase(); + // Strip one code span around the whole value only when it has no inner backticks, so a list of + // individually quoted names (`Read`, `Grep`) is left for the per-token pass. + const value = m[2]!.trim().replace(/^`([^`]*)`$/, '$1'); + if (label === 'tools') { + const tools = value.split(',').map((t) => t.trim().replace(/^`(.*)`$/, '$1')).filter(Boolean); + const bad = tools.filter((t) => !TOOL_RE.test(t)); + if (bad.length > 0) out.rejected.push(`Tools \`${bad.join(', ')}\` (not a tool name)`); + else if (tools.length > 0) out.tools = tools; + } else if (label === 'isolation') { + if (value.toLowerCase() === 'worktree') out.isolation = 'worktree'; + else out.rejected.push(`Isolation \`${value}\` (only \`worktree\` is supported)`); + } + } + return out; +} + +export interface RoutingRow { + workType: string; + routeTo: string; +} + +/** Rows of the `routing.md` table: `| Work Type | Route To | Examples |`. */ +export function readRouting(squadAbs: string): RoutingRow[] { + let text: string; + try { + text = readContained(squadAbs, 'routing.md'); + } catch { + return []; + } + const rows: RoutingRow[] = []; + for (const line of text.split(/\r?\n/)) { + const cells = line.split('|').slice(1, -1).map((c) => c.trim()); + if (cells.length < 2 || /^-+$/.test(cells[0]!.replace(/[:\s]/g, '')) || /^work type$/i.test(cells[0]!)) continue; + rows.push({ workType: cells[0]!, routeTo: cells[1]!.replace(/`/g, '').toLowerCase() }); + } + return rows; +} + +const EFFORTS = new Set(VALID_REASONING_EFFORTS); + +/** + * `agentReasoningEffortOverrides.`, then `defaultReasoningEffort`, from `.squad/config.json`. + * `auto`, invalid values and an unreadable config mean no config preference. + */ +function readEffortConfig(squadAbs: string): (id: string) => string | undefined { + let cfg: { defaultReasoningEffort?: unknown; agentReasoningEffortOverrides?: unknown } = {}; + try { + cfg = JSON.parse(fs.readFileSync(path.join(squadAbs, 'config.json'), 'utf8')); + } catch { + // missing or malformed config: no config preference + } + const valid = (v: unknown) => (typeof v === 'string' && EFFORTS.has(v.toLowerCase()) ? v.toLowerCase() : undefined); + const overrides = + cfg.agentReasoningEffortOverrides && typeof cfg.agentReasoningEffortOverrides === 'object' + ? (cfg.agentReasoningEffortOverrides as Record) + : {}; + return (id) => valid(Object.prototype.hasOwnProperty.call(overrides, id) ? overrides[id] : undefined) ?? valid(cfg.defaultReasoningEffort); +} + +function oneLine(s: string): string { + const flat = s.replace(/\s+/g, ' ').trim(); + return flat.length > MAX_DESCRIPTION ? `${flat.slice(0, MAX_DESCRIPTION - 1)}…` : flat; +} + +/** Member IDs with a charter, from `/agents//charter.md`. Symlinked directories are skipped. */ +function memberIds(agentsAbs: string): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(agentsAbs, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isDirectory() && ID_RE.test(e.name) && !RESERVED_IDS.has(e.name)) + .filter((e) => fs.existsSync(path.join(agentsAbs, e.name, 'charter.md'))) + .map((e) => e.name) + .sort(); +} + +export function renderClaudeMember( + id: string, + charter: string, + routing: readonly RoutingRow[], + squadDir: string, + generatedNote: string, + /** Reasoning effort from `.squad/config.json`, which outranks the charter (exec-prefs §3). */ + configEffort?: string, +): string { + const parsed = parseCharterMarkdown(charter); + const host = parseClaudeHostSettings(charter); + const name = parsed.identity.name ?? id; + const role = parsed.identity.role; + const notes = [...host.rejected]; + + const routed = routing.filter((r) => r.routeTo === id).map((r) => r.workType); + const summary = parsed.identity.purpose ?? (role ? `${name}, ${role}.` : `${name}, Squad team member.`); + const description = oneLine( + `${summary}${routed.length > 0 ? ` Owns: ${routed.join('; ')}.` : ''} Spawned by the Squad coordinator.`, + ); + + const fields: Array<[string, string]> = [ + ['name', id], + ['description', JSON.stringify(description)], + ]; + if (host.tools) fields.push(['tools', host.tools.join(', ')]); + + const preferred = parsed.modelPreference?.replace(/^`(.*)`$/, '$1').trim(); + const longContext = parsed.contextTier === 'long_context'; + if (preferred && preferred.toLowerCase() !== 'auto') { + if (CLAUDE_MODEL_RE.test(preferred)) fields.push(['model', preferred]); + else notes.push(`Model \`${preferred}\` (not a Claude model; the session model is used)`); + } + // Exec-prefs §6: an unsupported context tier is reported, not dropped silently. + if (longContext && !(preferred && /\[1m\]$/i.test(preferred))) { + notes.push('Context Tier `long_context` (name a long-context Claude model in `Preferred` to apply it)'); + } + const effort = configEffort ?? parsed.reasoningEffort; + if (effort) fields.push(['effort', effort]); + if (host.isolation) fields.push(['isolation', host.isolation]); + + const charterPath = `${squadDir}/agents/${id}/charter.md`; + const historyPath = `${squadDir}/agents/${id}/history.md`; + const notesBlock = + notes.length > 0 + ? `\n\n` + : ''; + + return ( + `---\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. ` + + `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 (\`mcp__squad_state__*\`) when they are available.\n` + ); +} + +/** + * Project one managed subagent per member charter, plus `obsolete` entries for generated member + * files whose charter is gone. Existing user-authored files (no `ownedMarker`) are never overwritten. + */ +export function projectClaudeMembers(ctx: ClientContext, generatedNote: string, ownedMarker: string): ProjectionFile[] { + const squadDir = ctx.squadDir ?? '.squad'; + const squadAbs = path.resolve(ctx.root, squadDir); + const agentsAbs = path.join(squadAbs, 'agents'); + const routing = readRouting(squadAbs); + const ids = memberIds(agentsAbs); + const effortConfig = readEffortConfig(squadAbs); + const files: ProjectionFile[] = []; + for (const id of ids) { + let charter: string; + try { + // A charter linked outside the team directory is never copied into .claude/. + charter = readContained(squadAbs, path.join(path.relative(squadAbs, agentsAbs), id, 'charter.md')); + } catch { + continue; + } + files.push({ + kind: 'managed', + path: `${CLAUDE_AGENTS_DIR}/${id}.md`, + ownedMarker, + content: renderClaudeMember(id, charter, routing, squadDir, generatedNote, effortConfig(id)), + }); + } + + const current = new Set(ids); + let existing: fs.Dirent[] = []; + try { + existing = fs.readdirSync(path.join(ctx.root, CLAUDE_AGENTS_DIR), { withFileTypes: true }); + } catch { + // no .claude/agents yet + } + for (const e of existing) { + const id = e.name.replace(/\.md$/, ''); + if (!e.isFile() || !e.name.endsWith('.md') || RESERVED_IDS.has(id) || current.has(id)) continue; + const rel = `${CLAUDE_AGENTS_DIR}/${e.name}`; + try { + if (fs.readFileSync(path.join(ctx.root, rel), 'utf8').includes(ownedMarker)) { + files.push({ kind: 'obsolete', path: rel, ownedMarker }); + } + } catch { + // unreadable: leave it alone + } + } + return files; +} diff --git a/packages/squad-sdk/src/client/adapters/claude-skills.ts b/packages/squad-sdk/src/client/adapters/claude-skills.ts new file mode 100644 index 000000000..cade5cf07 --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/claude-skills.ts @@ -0,0 +1,87 @@ +/** + * Claude Code skills: Squad's template skills plus the coordinator's on-demand `squad-coordinator-*` + * skills, projected into `.claude/skills//SKILL.md` (#2438 Phase 4). + * + * Skill precedence is unchanged: a team skill in `/skills//` outranks the template of + * the same name, so that template is not projected. Skill files stay untrusted data (core protocol + * item 17); projecting them changes how Claude Code discovers them, not what they are allowed to do. + * + * @module client/adapters/claude-skills + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { claudeCoordinatorSkills } from './coordinator.js'; +import { readContained } from './safe-path.js'; +import type { ClientContext, ProjectionFile } from './types.js'; + +export const CLAUDE_SKILLS_DIR = '.claude/skills'; + +const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function dirs(abs: string): string[] { + try { + return fs + .readdirSync(abs, { withFileTypes: true }) + .filter((e) => e.isDirectory() && NAME_RE.test(e.name)) + .map((e) => e.name) + .sort(); + } catch { + return []; + } +} + +/** Insert the generated-file note after the SKILL.md frontmatter (which must stay first). */ +function withNote(skill: string, note: string): string { + const text = skill.replace(/\r\n?/g, '\n'); + const fm = text.match(/^---\n[\s\S]*?\n---\n/); + return fm ? `${fm[0]}${note}\n${text.slice(fm[0].length)}` : `${note}\n\n${text}`; +} + +export function projectClaudeSkills(ctx: ClientContext, generatedNote: string, ownedMarker: string): ProjectionFile[] { + const squadDir = ctx.squadDir ?? '.squad'; + const squadAbs = path.resolve(ctx.root, squadDir); + const files: ProjectionFile[] = []; + const current = new Set(); + const add = (name: string, content: string) => { + current.add(name); + files.push({ kind: 'managed', path: `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md`, ownedMarker, content }); + }; + + for (const k of claudeCoordinatorSkills(squadDir, ctx.root)) { + add(k.name, `---\nname: ${k.name}\ndescription: ${JSON.stringify(k.description)}\n---\n${generatedNote}\n\n${k.body}`); + } + + const teamSkills = new Set(dirs(path.join(squadAbs, 'skills'))); + const templatesAbs = path.join(squadAbs, 'templates', 'skills'); + for (const name of dirs(templatesAbs)) { + if (teamSkills.has(name) || current.has(name)) continue; + let skill: string; + try { + // A SKILL.md (or directory) linked outside the team directory is never copied into .claude/. + skill = readContained(squadAbs, path.join('templates', 'skills', name, 'SKILL.md')); + } catch { + continue; + } + const extras = fs.readdirSync(path.join(templatesAbs, name)).filter((f) => f !== 'SKILL.md'); + const note = + extras.length > 0 + ? `${generatedNote}\n` + : generatedNote; + add(name, withNote(skill, note)); + } + + // Generated skills that are no longer projected (template removed, or now overridden by a team skill). + for (const name of dirs(path.join(ctx.root, CLAUDE_SKILLS_DIR))) { + if (current.has(name)) continue; + const rel = `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md`; + try { + if (fs.readFileSync(path.join(ctx.root, rel), 'utf8').includes(ownedMarker)) { + files.push({ kind: 'obsolete', path: rel, ownedMarker }); + } + } catch { + // no SKILL.md, or unreadable: leave it alone + } + } + return files; +} diff --git a/packages/squad-sdk/src/client/adapters/coordinator.ts b/packages/squad-sdk/src/client/adapters/coordinator.ts index 48281de73..c859a89ed 100644 --- a/packages/squad-sdk/src/client/adapters/coordinator.ts +++ b/packages/squad-sdk/src/client/adapters/coordinator.ts @@ -156,7 +156,11 @@ Spawn each specialist with the \`task\` tool (Copilot CLI), \`create_session\` ( summary: 'Claude Code Agent subagent', section: `## Dispatch adapter: Claude Code -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\`.`, +Spawn each specialist with the \`Agent\` tool. Each member has a Squad-generated subagent in \`.claude/agents/.md\` (model, reasoning effort and tool settings come from its charter and \`.squad/config.json\`): pass \`subagent_type: ""\`; the subagent reads its own charter, so give it the task, decisions and context rather than the charter text. If a member has no subagent file, use 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\`. + +**Model selection per spawn** (first match wins): a user directive for this spawn, then \`.squad/config.json\` \`agentModelOverrides.\`, then \`defaultModel\`, then a model the user chose for this session, then the charter's \`Preferred\`. Omit \`model\` when the charter (or nothing) decides: the member's frontmatter already applies it. Otherwise pass \`model\` on the \`Agent\` call as a Claude alias (\`sonnet\`, \`opus\`, \`haiku\`, \`fable\`; map a full Claude model ID to its family). \`auto\` means no preference. A non-Claude model cannot run here: say so in the spawn acknowledgment and use the next layer. **Reasoning effort** (\`agentReasoningEffortOverrides\`, \`defaultReasoningEffort\`, charter) is fixed in the frontmatter by \`squad upgrade\`; the \`Agent\` tool has no per-call effort, so tell the user when a per-spawn effort directive cannot be applied, and to re-run \`squad upgrade\` after changing effort config. A \`long_context\` context tier needs a long-context Claude model; otherwise report it as not applied. Show the model and its source in every spawn acknowledgment. + +**Reviewer lockout record.** On a REJECT, also record it with Bash: \`squad lockout reject --author --reviewer --reason "" --paths \`; then \`squad lockout assign --successor \` when you reassign, \`squad lockout submit\` when the revision is handed in, and \`squad lockout approve\` after re-review (\`squad lockout status\` lists eligible successors; \`deadlocked\` means escalate). Only the user or a listed authority may \`squad lockout override\`. When the project opts in (\`claudeHooks\` in \`.squad/config.json\`), Squad's Claude Code hooks read this record and warn when a locked-out member edits the subject's files.`, }, kiro: { summary: 'Kiro subagent + steering, MCP backend', @@ -199,5 +203,94 @@ export function buildCoordinatorInstructions(client: ClientId, squadDir = '.squa const caps = CAPABILITIES[client]; const transports = `## Transport\n\nThis host connects to the backend via: ${caps.transportModes.join(", ")} (subagents: ${caps.supportsSubagents ? "yes" : "no"}).\n`; const dispatch = STRATEGIES[client].section.split('.squad/').join(`${squadDir}/`); - return `${coreCoordinatorProtocol(squadDir, repoRoot)}\n${dispatch}\n\n${transports}`; + let core = coreCoordinatorProtocol(squadDir, repoRoot); + if (client === 'claude') core = splitOnDemandSections(core, squadDir).core; + return `${core}\n${dispatch}\n\n${transports}`; +} + +/** + * Core-protocol items the Claude Code coordinator loads on demand as skills (#2438 Phase 4). The digest + * keeps a stub per item with its trigger and any rule that must hold before the skill is loaded; the full + * item text moves verbatim into a `.claude/skills//SKILL.md`. Other hosts keep the full digest. + */ +interface OnDemandSection { + /** Item marker at the start of the line, e.g. `9. **Ralph mode`. */ + marker: string; + skill: string; + title: string; + /** Claude Code skill description: when to load it. */ + description: string; + stub: (skill: string, S: string) => string; +} + +const ON_DEMAND: readonly OnDemandSection[] = [ + { + marker: '0. **Init Mode', + skill: 'squad-coordinator-init-mode', + title: 'Init Mode and casting', + description: 'Squad coordinator only: Init Mode, proposing and casting a new team. Load when team.md is missing or has an empty roster, or when asked to set up or cast a team.', + stub: (k, S) => `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 \`${S}/\`). 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 \`${k}\` skill and follow it before doing anything else.`, + }, + { + marker: '9. **Ralph mode', + skill: 'squad-coordinator-ralph-mode', + title: 'Ralph mode (work monitor)', + description: 'Squad coordinator only: Ralph mode, the backlog work monitor loop. Load when the user says "Ralph, go" or asks to work the backlog or keep the board moving.', + stub: (k) => `9. **Ralph mode (work monitor).** When the user says "Ralph, go" (or asks you to work the backlog / keep the board moving), load the \`${k}\` skill and run its loop yourself: never delegate the loop itself.`, + }, + { + marker: '15. **Ceremonies.', + skill: 'squad-coordinator-ceremonies', + title: 'Ceremonies', + description: 'Squad coordinator only: running team ceremonies (design review, retro, manual ceremonies). Load before spawning work when ceremonies.md defines a before or after trigger, or when a ceremony is requested.', + stub: (k, S) => `15. **Ceremonies.** If \`${S}/ceremonies.md\` defines ceremonies, load the \`${k}\` skill before spawning work (for \`before\` triggers), after work completes (for \`after\` triggers), or when the user asks for one.`, + }, + { + marker: '18. **GitHub Issues mode.', + skill: 'squad-coordinator-issues-mode', + title: 'GitHub Issues mode', + description: 'Squad coordinator only: GitHub Issues mode (connecting a repo, listing, routing and working issues with gh). Load when the user mentions issues, connecting a repo, or squad labels.', + stub: (k) => `18. **GitHub Issues mode.** When the user asks to connect a repository or work GitHub issues, load the \`${k}\` skill and follow it.`, + }, + { + marker: '19. **PRD mode.', + skill: 'squad-coordinator-prd-mode', + title: 'PRD mode', + description: 'Squad coordinator only: PRD mode (decomposing a product requirements document into owned work items). Load when the user provides or points to a PRD or spec to break down.', + stub: (k) => `19. **PRD mode.** When given a PRD (file or pasted), load the \`${k}\` skill and follow it.`, + }, +]; + +export interface CoordinatorSkill { + name: string; + description: string; + /** Markdown body without frontmatter. */ + body: string; +} + +/** Item boundary: the next numbered item or section heading. */ +const ITEM_START = /^(?:\d+a?\. \*\*|## )/; + +function splitOnDemandSections(core: string, squadDir: string): { core: string; skills: CoordinatorSkill[] } { + const lines = core.split('\n'); + const skills: CoordinatorSkill[] = []; + for (const sec of ON_DEMAND) { + const start = lines.findIndex((l) => l.startsWith(sec.marker)); + if (start === -1) throw new Error(`coordinator digest: on-demand item "${sec.marker}" not found`); + let end = start + 1; + while (end < lines.length && !ITEM_START.test(lines[end]!) && lines[end] !== '') end++; + const item = lines.slice(start, end).join('\n'); + skills.push({ + name: sec.skill, + description: sec.description, + body: `# ${sec.title}\n\nThis is the full text of core-protocol item ${sec.marker.split('.')[0]} of the Squad coordinator, loaded on demand. It applies only to the Squad coordinator, never to team members.\n\n${item.replace(/^\d+\. /, '')}\n`, + }); + lines.splice(start, end - start, sec.stub(sec.skill, squadDir)); + } + return { core: lines.join('\n'), skills }; +} + +/** The `squad-coordinator-*` skills the Claude Code coordinator loads on demand. */ +export function claudeCoordinatorSkills(squadDir = '.squad', repoRoot?: string): CoordinatorSkill[] { + return splitOnDemandSections(coreCoordinatorProtocol(squadDir, repoRoot), squadDir).skills; } diff --git a/packages/squad-sdk/src/client/adapters/index.ts b/packages/squad-sdk/src/client/adapters/index.ts index 44385ccbb..ad9ca8bb6 100644 --- a/packages/squad-sdk/src/client/adapters/index.ts +++ b/packages/squad-sdk/src/client/adapters/index.ts @@ -4,3 +4,5 @@ export * from './adapters.js'; export * from './apply.js'; export * from './capabilities.js'; export * from './portable.js'; +export * from './lockout.js'; +export * from './claude-hooks.js'; diff --git a/packages/squad-sdk/src/client/adapters/lockout.ts b/packages/squad-sdk/src/client/adapters/lockout.ts new file mode 100644 index 000000000..a58518cf6 --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/lockout.ts @@ -0,0 +1,329 @@ +/** + * Durable reviewer-lockout record (governance-review v0.1 §6), stored in + * `/governance/lockouts.json`. + * + * Each review subject carries its own lockout set. A rejection adds the current revision author; + * the set stays in force until a later revision is approved or an authority records an override. + * When no eligible successor remains, the subject is `deadlocked` instead of silently readmitting + * a locked-out author. Hosts read this record (for example the Claude Code PreToolUse hook); it + * grants or removes no repository permission (§10). + * + * @module client/adapters/lockout + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveContained } from './safe-path.js'; + +export const LOCKOUT_FILE = 'governance/lockouts.json'; + +export type LockoutState = 'clear' | 'author-locked' | 'revision-assigned' | 'revision-submitted' | 'cleared' | 'deadlocked'; + +export interface LockoutOverride { + /** Author readmitted for this subject. */ + author: string; + /** Authority that recorded the override. */ + by: string; + reason: string; + at: string; +} + +export interface LockoutRecord { + /** Review subject: an issue, PR or artifact reference. */ + subject: string; + /** Repository-relative paths or globs the subject covers. Empty: the whole repository. */ + paths: string[]; + state: LockoutState; + /** Every author a rejection has locked out, in order. Overrides are recorded separately. */ + lockedAuthors: string[]; + reviewer?: string; + successor?: string; + /** Reason given with the latest rejection. */ + reason?: string; + overrides: LockoutOverride[]; + updatedAt: string; +} + +export interface LockoutStore { + version: 1; + subjects: LockoutRecord[]; +} + +export class LockoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'LockoutError'; + } +} + +/** + * Members that never author revisions: the coordinator, the bookkeeping agents, and the + * reviewer-only built-ins whose charters exclude implementation and revision pairing. + */ +const NON_AUTHORS = new Set(['squad', 'scribe', 'ralph', 'rai', 'fact-checker']); +const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function emptyLockoutStore(): LockoutStore { + return { version: 1, subjects: [] }; +} + +export function readLockouts(squadAbs: string): LockoutStore { + let raw: string; + try { + raw = fs.readFileSync(lockoutPath(squadAbs), 'utf8'); + } catch (err) { + if (err instanceof LockoutError) throw err; + return emptyLockoutStore(); + } + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== 1 || !Array.isArray(parsed.subjects)) { + throw new LockoutError(`${LOCKOUT_FILE}: unsupported lockout record (expected version 1)`); + } + return { version: 1, subjects: parsed.subjects.map((r) => ({ ...r, paths: r.paths ?? [], overrides: r.overrides ?? [] })) }; +} + +/** The lockout file, refusing a `governance/` directory or file link that leads outside the team. */ +function lockoutPath(squadAbs: string): string { + try { + return resolveContained(squadAbs, LOCKOUT_FILE); + } catch (err) { + throw new LockoutError(err instanceof Error ? err.message : String(err)); + } +} + +const LOCK_STALE_MS = 30_000; +const LOCK_WAIT_MS = 10_000; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Read, transform and write the store under an interprocess lock, so concurrent `squad lockout` + * commands (for example two coordinator sessions rejecting different subjects) keep both + * transitions. A lock older than 30 s is treated as abandoned. + */ +export function updateLockouts(squadAbs: string, transition: (store: LockoutStore) => LockoutStore): LockoutStore { + const abs = lockoutPath(squadAbs); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + const lock = `${abs}.lock`; + const deadline = Date.now() + LOCK_WAIT_MS; + for (;;) { + try { + fs.closeSync(fs.openSync(lock, 'wx')); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + try { + if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmSync(lock, { force: true }); + } catch { + // released meanwhile + } + if (Date.now() > deadline) throw new LockoutError(`${LOCKOUT_FILE} is locked by another squad lockout command; try again`); + sleepSync(50); + } + } + try { + const next = transition(readLockouts(squadAbs)); + writeLockouts(squadAbs, next); + return next; + } finally { + fs.rmSync(lock, { force: true }); + } +} + +export function writeLockouts(squadAbs: string, store: LockoutStore): void { + const abs = lockoutPath(squadAbs); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + // Rename replaces a link at `abs` instead of following it. + const tmp = `${abs}.${process.pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`); + fs.renameSync(tmp, abs); +} + +/** Authors who may revise: member IDs with a charter, minus the coordinator and the non-authoring built-ins. */ +export function lockoutRoster(squadAbs: string): string[] { + try { + return fs + .readdirSync(path.join(squadAbs, 'agents'), { withFileTypes: true }) + .filter((e) => e.isDirectory() && ID_RE.test(e.name) && !NON_AUTHORS.has(e.name)) + .filter((e) => fs.existsSync(path.join(squadAbs, 'agents', e.name, 'charter.md'))) + .map((e) => e.name) + .sort(); + } catch { + return []; + } +} + +/** Who may record an override: the human user, plus `governance.authorities` from config.json. */ +export function lockoutAuthorities(squadAbs: string): string[] { + let cfg: { governance?: { authorities?: unknown } } = {}; + try { + cfg = JSON.parse(fs.readFileSync(path.join(squadAbs, 'config.json'), 'utf8')); + } catch { + // no config: only the user + } + const listed = Array.isArray(cfg.governance?.authorities) + ? cfg.governance!.authorities.filter((a): a is string => typeof a === 'string').map((a) => a.toLowerCase()) + : []; + return ['user', ...listed.filter((a) => a !== 'user')]; +} + +/** Authors currently locked out of the subject (none once it is clear or cleared). */ +export function effectiveLocked(record: LockoutRecord): string[] { + if (record.state === 'clear' || record.state === 'cleared') return []; + const readmitted = new Set(record.overrides.map((o) => o.author)); + return record.lockedAuthors.filter((a) => !readmitted.has(a)); +} + +/** Roster members who may author the next revision: not locked out, and not the blocking reviewer. */ +export function eligibleSuccessors(record: LockoutRecord, roster: readonly string[]): string[] { + const locked = new Set(effectiveLocked(record)); + return roster.filter((m) => !locked.has(m) && m !== record.reviewer); +} + +function norm(id: string, what: string): string { + const v = id.trim().toLowerCase(); + if (!v) throw new LockoutError(`${what} is required`); + return v; +} + +function find(store: LockoutStore, subject: string): LockoutRecord { + const r = store.subjects.find((s) => s.subject === subject); + if (!r) throw new LockoutError(`No lockout record for ${subject}`); + return r; +} + +function put(store: LockoutStore, record: LockoutRecord): LockoutStore { + return { version: 1, subjects: [...store.subjects.filter((s) => s.subject !== record.subject), record] }; +} + +function settle(record: LockoutRecord, roster: readonly string[] | undefined): LockoutRecord { + if (roster && roster.length > 0 && eligibleSuccessors(record, roster).length === 0) return { ...record, state: 'deadlocked' }; + return record; +} + +export interface RejectInput { + subject: string; + /** Current revision author, who is locked out. */ + author: string; + reviewer: string; + reason: string; + paths?: string[]; + /** Eligible-author pool; when given, an empty eligible set records `deadlocked`. */ + roster?: readonly string[]; + at?: string; +} + +/** A blocking REJECT: lock the current revision author out of the subject. */ +export function rejectRevision(store: LockoutStore, input: RejectInput): LockoutStore { + const author = norm(input.author, 'author'); + const reviewer = norm(input.reviewer, 'reviewer'); + if (author === reviewer) throw new LockoutError(`Self-review: ${author} cannot review their own revision`); + if (!input.reason.trim()) throw new LockoutError('A rejection needs a reason (the violated requirement and clearing condition)'); + const prev = store.subjects.find((s) => s.subject === input.subject); + const active = prev && prev.state !== 'cleared' && prev.state !== 'clear' ? prev : undefined; + const record: LockoutRecord = { + subject: input.subject, + paths: input.paths ?? active?.paths ?? [], + state: 'author-locked', + lockedAuthors: [...new Set([...(active?.lockedAuthors ?? []), author])], + reviewer, + reason: input.reason.trim(), + // An override readmits an author for one cycle; a new rejection of the same author locks them again. + overrides: (active?.overrides ?? []).filter((o) => o.author !== author), + updatedAt: input.at ?? new Date().toISOString(), + }; + return put(store, settle(record, input.roster)); +} + +/** Assign the next revision to a successor from the eligible set. */ +export function assignRevision( + store: LockoutStore, + input: { subject: string; successor: string; roster?: readonly string[]; at?: string }, +): LockoutStore { + const record = find(store, input.subject); + const successor = norm(input.successor, 'successor'); + if (record.state !== 'author-locked' && record.state !== 'revision-assigned') { + throw new LockoutError(`${input.subject} is ${record.state}; only a locked subject can be reassigned`); + } + if (effectiveLocked(record).includes(successor)) throw new LockoutError(`${successor} is locked out of ${input.subject}`); + if (successor === record.reviewer) throw new LockoutError(`${successor} is the blocking reviewer and cannot author the revision`); + if (input.roster && input.roster.length > 0 && !input.roster.includes(successor)) { + throw new LockoutError(`${successor} is not on the roster`); + } + return put(store, { ...record, state: 'revision-assigned', successor, updatedAt: input.at ?? new Date().toISOString() }); +} + +/** The successor submits the revision for re-review. */ +export function submitRevision(store: LockoutStore, input: { subject: string; author: string; at?: string }): LockoutStore { + const record = find(store, input.subject); + const author = norm(input.author, 'author'); + if (record.state !== 'revision-assigned') throw new LockoutError(`${input.subject} is ${record.state}; nothing is assigned`); + if (author !== record.successor) throw new LockoutError(`${author} is not the assigned successor (${record.successor})`); + return put(store, { ...record, state: 'revision-submitted', updatedAt: input.at ?? new Date().toISOString() }); +} + +/** Re-review approved the later revision: the lockout ends. */ +export function approveRevision(store: LockoutStore, input: { subject: string; reviewer: string; at?: string }): LockoutStore { + const record = find(store, input.subject); + const reviewer = norm(input.reviewer, 'reviewer'); + if (record.state !== 'revision-submitted') throw new LockoutError(`${input.subject} is ${record.state}; no revision awaits review`); + if (reviewer === record.successor) throw new LockoutError(`Self-approval: ${reviewer} authored the revision`); + return put(store, { ...record, state: 'cleared', reviewer, updatedAt: input.at ?? new Date().toISOString() }); +} + +/** An authority readmits a locked-out author for this subject. */ +export function overrideLockout( + store: LockoutStore, + input: { subject: string; author: string; by: string; reason: string; authorities: readonly string[]; roster?: readonly string[]; at?: string }, +): LockoutStore { + const record = find(store, input.subject); + const author = norm(input.author, 'author'); + const by = norm(input.by, 'authority'); + if (!input.authorities.includes(by)) throw new LockoutError(`${by} is not an authority (authorities: ${input.authorities.join(', ')})`); + if (by === author) throw new LockoutError(`${author} cannot override their own lockout`); + if (!input.reason.trim()) throw new LockoutError('An override needs a reason'); + if (!effectiveLocked(record).includes(author)) throw new LockoutError(`${author} is not locked out of ${input.subject}`); + const at = input.at ?? new Date().toISOString(); + const next: LockoutRecord = { + ...record, + overrides: [...record.overrides, { author, by, reason: input.reason.trim(), at }], + updatedAt: at, + }; + // A deadlock resolved by readmitting an author goes back to waiting for a successor. + if (next.state === 'deadlocked') next.state = 'author-locked'; + return put(store, settle(next, input.roster)); +} + +function globToRegExp(glob: string): RegExp { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]!; + if (c === '*' && glob[i + 1] === '*') { + const slash = glob[i + 2] === '/'; + re += slash ? '(?:.*/)?' : '.*'; + i += slash ? 2 : 1; + } else if (c === '*') re += '[^/]*'; + else if (c === '?') re += '[^/]'; + else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${re}$`); +} + +/** Does a repository-relative POSIX path fall under one of the subject's paths? */ +export function subjectCovers(record: LockoutRecord, relPath: string): boolean { + if (record.paths.length === 0) return true; + const p = relPath.replace(/\\/g, '/').replace(/^\.\//, ''); + return record.paths.some((raw) => { + const g = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''); + if (/[*?]/.test(g)) return globToRegExp(g).test(p); + return p === g || p.startsWith(`${g}/`); + }); +} + +/** Active lockouts that cover `relPath` for `author`. */ +export function lockoutsFor(store: LockoutStore, author: string, relPath: string): LockoutRecord[] { + const a = author.toLowerCase(); + return store.subjects.filter((r) => effectiveLocked(r).includes(a) && subjectCovers(r, relPath)); +} diff --git a/packages/squad-sdk/src/client/adapters/safe-path.ts b/packages/squad-sdk/src/client/adapters/safe-path.ts new file mode 100644 index 000000000..041eabb59 --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/safe-path.ts @@ -0,0 +1,52 @@ +/** + * Containment for files Squad reads from, or writes to, a team directory that the repository + * controls. A symlink anywhere on the path (a directory component or the file itself) may not + * lead outside `root`; links that stay inside are fine. + * + * @module client/adapters/safe-path + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export class OutsideRootError extends Error { + constructor(rel: string) { + super(`Refusing ${rel}: it resolves outside the team directory`); + this.name = 'OutsideRootError'; + } +} + +function inside(abs: string, root: string): boolean { + const rel = path.relative(root, abs); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +/** + * Resolve `rel` under `root` through any symlinks, following the deepest existing ancestor for a + * path that does not exist yet. Throws {@link OutsideRootError} when the result leaves `root`. + */ +export function resolveContained(root: string, rel: string): string { + const rootReal = fs.realpathSync(root); + let dir = path.resolve(root, rel); + const rest: string[] = []; + for (;;) { + let real: string | undefined; + try { + real = fs.realpathSync(dir); + } catch { + const parent = path.dirname(dir); + if (parent === dir) throw new OutsideRootError(rel); + rest.unshift(path.basename(dir)); + dir = parent; + continue; + } + const full = path.join(real, ...rest); + if (!inside(full, rootReal)) throw new OutsideRootError(rel); + return full; + } +} + +/** Read a UTF-8 file under `root`, refusing one that resolves outside it. */ +export function readContained(root: string, rel: string): string { + return fs.readFileSync(resolveContained(root, rel), 'utf8'); +} diff --git a/packages/squad-sdk/src/client/adapters/types.ts b/packages/squad-sdk/src/client/adapters/types.ts index 55328dac8..6668bed96 100644 --- a/packages/squad-sdk/src/client/adapters/types.ts +++ b/packages/squad-sdk/src/client/adapters/types.ts @@ -28,10 +28,27 @@ export interface ClientContext { /** A projected file, relative to {@link ClientContext.root}. */ export type ProjectionFile = - /** Fully Squad-owned file; overwritten on every refresh. */ - | { kind: 'managed'; path: string; content: string } - /** Read-modify-write of a user-shared JSON config; only Squad keys are touched. */ - | { kind: 'json'; path: string; update: (existing: Record) => Record } + /** + * Fully Squad-owned file; overwritten on every refresh. With `ownedMarker`, an existing file that + * lacks the marker is user-authored: apply skips it (action `skipped`) and rollback leaves it. + */ + | { kind: 'managed'; path: string; content: string; ownedMarker?: string } + /** + * A previously generated file that the current projection no longer produces (e.g. a removed + * team member). Apply and rollback delete it only while it still carries `ownedMarker`. + */ + | { kind: 'obsolete'; path: string; ownedMarker: string } + /** + * Read-modify-write of a user-shared JSON config; only Squad keys are touched. `remove` undoes + * `update` on rollback (default: drop the `squad_state` MCP entry). An update that leaves an + * empty object never creates the file, and removes it when it exists. + */ + | { + kind: 'json'; + path: string; + update: (existing: Record) => Record; + remove?: (existing: Record) => Record; + } /** Marker-delimited block inside a user-owned text file. */ | { kind: 'block'; path: string; content: string; id?: string } /** @@ -78,10 +95,11 @@ export interface HostProjection { project(ctx: ClientContext): ProjectionFile[]; } -export type ApplyAction = 'created' | 'updated' | 'unchanged' | 'removed' | 'error'; +export type ApplyAction = 'created' | 'updated' | 'unchanged' | 'removed' | 'skipped' | 'error'; export interface ApplyResult { path: string; action: ApplyAction; + /** Failure reason for `error`, or why the file was left alone for `skipped`. */ error?: string; } 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 aab13212f..cfc92bde9 100644 --- a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json +++ b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json @@ -166,8 +166,9 @@ "claude": { "capability": "supported", "validation": "validated-live", - "mechanism": "CLI scaffolds `.squad/`; coordinator Init Mode (digest §0) uses `squad_state_read team.md`, auto-confirms headless, casts via `squad cast --name`, never hand-edits `.squad/casting/`.", + "mechanism": "CLI scaffolds `.squad/`; the digest stub (§0) decides Init Mode with `squad_state_read team.md`, never glob, and loads the `squad-coordinator-init-mode` skill, which auto-confirms headless, casts via `squad cast --name` and never hand-edits `.squad/casting/`.", "evidence": [ + "validated-test:test/claude-skills.test.ts::*", "validated-test:test/clients.test.ts::coordinator behavioral-contract parity", "validated-test:test/squad-20-all-scenarios.e2e.test.ts::field findings from the live demo: Init Mode, scratch files, casting registry", "validated-test:test/cli/init.test.ts::*", @@ -438,15 +439,13 @@ "notes": "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." }, "claude": { - "capability": "unsupported", + "capability": "supported", "validation": "validated-test", - "expectedFailure": true, - "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.", + "mechanism": "Each member's `.claude/agents/.md` carries the charter's `Preferred` model (Claude models and aliases only) and the reasoning effort resolved from `.squad/config.json` (`agentReasoningEffortOverrides`, `defaultReasoningEffort`) over the charter. The Claude dispatch section applies the higher-precedence model layers (user directive, `agentModelOverrides`, `defaultModel`, session choice) as the per-call `Agent` `model`. A non-Claude model, a per-spawn effort directive (the `Agent` tool has no per-call effort) and `long_context` without a long-context Claude model are reported, not applied.", "evidence": [ - "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): unsupported on this host until the section ships." + "validated-test:test/claude-members.test.ts::*", + "validated-test:test/clients.test.ts::coordinator digest vs. host feature matrix" + ] }, "kiro": { "capability": "unsupported", @@ -1100,8 +1099,9 @@ "claude": { "capability": "supported", "validation": "validated-live", - "mechanism": "Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence.", + "mechanism": "Digest §17 lists `.squad/skills/` → `.copilot/skills/` → `.github/skills/` → `.claude/skills/` → `.agents/skills/` with earlier-wins precedence. Squad's template skills are also projected as native skills in `.claude/skills/`, except where a team skill of the same name exists in `.squad/skills/`.", "evidence": [ + "validated-test:test/claude-skills.test.ts::*", "validated-test:test/clients.test.ts::coordinator behavioral-contract parity", "validated-test:test/skills.test.ts::*", "validated-manual:lab:skills-precedence@2026-10-22" @@ -1189,9 +1189,11 @@ "claude": { "capability": "supported", "validation": "validated-live", - "mechanism": "Digest §12: REJECT locks out the author; revision reassigned; re-review required.", + "mechanism": "Digest §12: REJECT locks out the author; revision reassigned; re-review required. `squad lockout` records the §6 lockout state in `.squad/governance/lockouts.json`; with `claudeHooks: \"advisory\"`, a PreToolUse hook warns a locked-out member that edits a covered path (advisory: never denies).", "evidence": [ "validated-test:test/clients.test.ts::coordinator behavioral-contract parity", + "validated-test:test/claude-hooks.test.ts::runs reject → assign → submit → approve, and approval clears the lockout", + "validated-test:test/claude-hooks.test.ts::PreToolUse warns when a locked-out member edits a subject file, and never denies", "validated-manual:lab:reviewer-lockout@2026-10-22" ] }, @@ -1440,8 +1442,9 @@ "claude": { "capability": "supported", "validation": "validated-test", - "mechanism": "Digest §15 reads `ceremonies.md`, spawns a facilitator, records via `squad_decide`.", + "mechanism": "The digest stub (§15) loads the `squad-coordinator-ceremonies` skill, which reads `ceremonies.md`, spawns a facilitator and records via `squad_decide`.", "evidence": [ + "validated-test:test/claude-skills.test.ts::*", "validated-test:test/clients.test.ts::coordinator behavioral-contract parity", "static-only" ] @@ -1701,8 +1704,9 @@ "claude": { "capability": "supported", "validation": "validated-live", - "mechanism": "Digest §9: coordinator runs the loop itself via `gh`; circuit breaker template. Requires `gh` authenticated inside the host.", + "mechanism": "The digest stub (§9) loads the `squad-coordinator-ralph-mode` skill; the coordinator runs the loop itself via `gh`, with the circuit breaker template. Requires `gh` authenticated inside the host.", "evidence": [ + "validated-test:test/claude-skills.test.ts::*", "validated-test:test/clients.test.ts::coordinator Ralph mode", "validated-test:test/clients.test.ts::coordinator behavioral-contract parity", "validated-manual:lab:ralph-no-repo@2026-10-22" @@ -1789,12 +1793,12 @@ ] }, "claude": { - "capability": "degraded", + "capability": "supported", "validation": "validated-test", - "mechanism": "Scanning/routing is host-agnostic, but `--execute` dispatches through the Copilot CLI unless `agentCmd` (`--agent-cmd \" … {prompt}\"`) is configured; no built-in preset for this host.", + "mechanism": "Built-in preset: `--agent-preset claude` (or `watch.agentPreset: \"claude\"`) runs `claude -p {prompt} --agent squad --mcp-config --output-format stream-json --verbose` (the config is written by the CLI; the repository's `.mcp.json` is never passed); execute mode defaults to `--permission-mode bypassPermissions`. Fleet dispatch stays Copilot-only.", "evidence": [ "validated-test:test/ralph-triage.test.ts::*", - "static-only" + "validated-test:test/cli/watch-claude-preset.test.ts::*" ] }, "kiro": { @@ -1874,8 +1878,9 @@ "claude": { "capability": "supported", "validation": "validated-test", - "mechanism": "Digest §19; `prd-intake.md` template on demand.", + "mechanism": "The digest stub (§19) loads the `squad-coordinator-prd-mode` skill; `prd-intake.md` template on demand.", "evidence": [ + "validated-test:test/claude-skills.test.ts::*", "validated-test:test/clients.test.ts::coordinator behavioral-contract parity", "static-only" ] @@ -3870,10 +3875,11 @@ "claude": { "capability": "n/a", "validation": "static-only", - "mechanism": "Runs in GitHub Actions with the Copilot engine; the local host is irrelevant.", + "mechanism": "Runs in GitHub Actions, so the local host is irrelevant. Squad's workflows use the Copilot engine; gh-aw also has a built-in Claude Code engine (`engine: claude`), which Squad does not ship a workflow for.", "evidence": [ "static-only" - ] + ], + "notes": "Re-evaluated for #2442 (research: https://github.com/github/squad/issues/2438#issuecomment-6753729924). A Squad workflow on `engine: claude` would pass `engine.args: [\"--agent\", \"squad\"]` (gh-aw has no native agent selection for Claude) and needs `bare: false` for subagent delegation; not yet run." }, "kiro": { "capability": "n/a", @@ -5049,7 +5055,6 @@ "perHost": true, "hosts": [ "copilot", - "claude", "kiro", "opencode", "cursor", diff --git a/test/claude-hooks.test.ts b/test/claude-hooks.test.ts new file mode 100644 index 000000000..0ebff69dc --- /dev/null +++ b/test/claude-hooks.test.ts @@ -0,0 +1,332 @@ +/** + * Claude Code governance hooks and the durable reviewer-lockout record (#2442 Phase 3). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { + installClients, + uninstallClients, +} from '../packages/squad-sdk/src/client/adapters/index.js'; +import { + CLAUDE_SETTINGS_PATH, + isSquadHookHandler, + mergeClaudeHooks, + runClaudeHook, + stripClaudeHooks, + type ClaudeHookEnv, +} from '../packages/squad-sdk/src/client/adapters/claude-hooks.js'; +import { + LockoutError, + approveRevision, + assignRevision, + effectiveLocked, + eligibleSuccessors, + emptyLockoutStore, + overrideLockout, + readLockouts, + rejectRevision, + submitRevision, + subjectCovers, + writeLockouts, + type LockoutStore, +} from '../packages/squad-sdk/src/client/adapters/lockout.js'; + +const MCP = { command: 'squad', args: ['state-mcp'] }; +const ROSTER = ['dallas', 'lambert', 'ripley']; +const AT = '2026-11-09T12:00:00.000Z'; + +const reject = (store: LockoutStore, author = 'dallas', reviewer = 'ripley', roster: readonly string[] = ROSTER) => + rejectRevision(store, { subject: 'issue-42', author, reviewer, reason: 'missing tests for the auth path', paths: ['src/auth/**'], roster, at: AT }); +const subject = (s: LockoutStore) => s.subjects.find((r) => r.subject === 'issue-42')!; + +describe('reviewer lockout record (governance-review §6)', () => { + it('a REJECT locks the author out, scoped to the subject paths', () => { + const s = reject(emptyLockoutStore()); + expect(subject(s)).toMatchObject({ state: 'author-locked', lockedAuthors: ['dallas'], reviewer: 'ripley' }); + expect(eligibleSuccessors(subject(s), ROSTER)).toEqual(['lambert']); + expect(subjectCovers(subject(s), 'src/auth/login.ts')).toBe(true); + expect(subjectCovers(subject(s), 'src/billing/pay.ts')).toBe(false); + }); + + it('rejects self-review, a locked-out successor, and the reviewer as successor', () => { + expect(() => reject(emptyLockoutStore(), 'ripley', 'ripley')).toThrow(LockoutError); + const s = reject(emptyLockoutStore()); + expect(() => assignRevision(s, { subject: 'issue-42', successor: 'dallas' })).toThrow(/locked out/); + expect(() => assignRevision(s, { subject: 'issue-42', successor: 'ripley' })).toThrow(/blocking reviewer/); + }); + + it('runs reject → assign → submit → approve, and approval clears the lockout', () => { + let s = reject(emptyLockoutStore()); + s = assignRevision(s, { subject: 'issue-42', successor: 'lambert', roster: ROSTER }); + expect(() => submitRevision(s, { subject: 'issue-42', author: 'dallas' })).toThrow(/not the assigned successor/); + s = submitRevision(s, { subject: 'issue-42', author: 'lambert' }); + expect(() => approveRevision(s, { subject: 'issue-42', reviewer: 'lambert' })).toThrow(/Self-approval/); + s = approveRevision(s, { subject: 'issue-42', reviewer: 'ripley' }); + expect(subject(s).state).toBe('cleared'); + expect(effectiveLocked(subject(s))).toEqual([]); + }); + + it('records a deadlock when no eligible successor remains, instead of readmitting an author', () => { + let s = reject(emptyLockoutStore(), 'dallas', 'ripley', ['dallas', 'lambert', 'ripley']); + s = rejectRevision(s, { subject: 'issue-42', author: 'lambert', reviewer: 'ripley', reason: 'still no tests', roster: ['dallas', 'lambert', 'ripley'], at: AT }); + expect(subject(s)).toMatchObject({ state: 'deadlocked', lockedAuthors: ['dallas', 'lambert'] }); + expect(() => assignRevision(s, { subject: 'issue-42', successor: 'dallas' })).toThrow(/deadlocked/); + }); + + it('an override needs an authority and a reason; it readmits the author and resolves a deadlock', () => { + let s = reject(emptyLockoutStore(), 'dallas', 'ripley', ['dallas', 'ripley']); + expect(subject(s).state).toBe('deadlocked'); + const base = { subject: 'issue-42', author: 'dallas', reason: 'only backend dev; pairing with Ripley', authorities: ['user', 'ripley'], roster: ['dallas', 'ripley'] }; + expect(() => overrideLockout(s, { ...base, by: 'lambert' })).toThrow(/not an authority/); + expect(() => overrideLockout(s, { ...base, by: 'dallas', authorities: ['user', 'dallas'] })).toThrow(/own lockout/); + expect(() => overrideLockout(s, { ...base, by: 'user', reason: ' ' })).toThrow(/reason/); + s = overrideLockout(s, { ...base, by: 'user', at: AT }); + expect(subject(s)).toMatchObject({ state: 'author-locked', overrides: [{ author: 'dallas', by: 'user', at: AT }] }); + expect(effectiveLocked(subject(s))).toEqual([]); + expect(() => assignRevision(s, { subject: 'issue-42', successor: 'dallas' })).not.toThrow(); + }); + + it('persists to /governance/lockouts.json', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-lockout-')); + try { + writeLockouts(dir, reject(emptyLockoutStore())); + expect(readLockouts(dir)).toEqual(reject(emptyLockoutStore())); + expect(fs.existsSync(path.join(dir, 'governance/lockouts.json'))).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('Claude hooks in .claude/settings.json', () => { + let root: string; + const settingsAbs = () => path.join(root, CLAUDE_SETTINGS_PATH); + const config = (cfg: Record) => fs.writeFileSync(path.join(root, '.squad/config.json'), `${JSON.stringify(cfg, null, 2)}\n`); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-hooks-')); + fs.mkdirSync(path.join(root, '.squad'), { recursive: true }); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('is off by default: no settings file is written', () => { + installClients(['claude'], { root, mcp: MCP }); + expect(fs.existsSync(settingsAbs())).toBe(false); + }); + + it('opted in, adds one Squad handler per event that runs the MCP launcher with `hook claude `', () => { + config({ claudeHooks: 'advisory' }); + installClients(['claude'], { root, mcp: { command: 'npx', args: ['-y', '@bradygaster/squad-cli@1.2.3', 'state-mcp'] } }); + const hooks = JSON.parse(fs.readFileSync(settingsAbs(), 'utf8')).hooks; + expect(Object.keys(hooks)).toEqual(['SessionStart', 'SubagentStart', 'PreToolUse', 'PostToolUse', 'Stop']); + expect(hooks.PreToolUse).toEqual([ + { + matcher: 'Edit|MultiEdit|Write|NotebookEdit', + hooks: [{ type: 'command', command: 'npx', args: ['-y', '@bradygaster/squad-cli@1.2.3', 'hook', 'claude', 'PreToolUse'], timeout: 15 }], + }, + ]); + expect(JSON.stringify(hooks)).not.toContain('permissionDecision'); + expect(installClients(['claude'], { root, mcp: MCP }).find((r) => r.path === CLAUDE_SETTINGS_PATH)?.action).toBe('updated'); + expect(installClients(['claude'], { root, mcp: MCP }).find((r) => r.path === CLAUDE_SETTINGS_PATH)?.action).toBe('unchanged'); + }); + + it('keeps user hooks and settings, and rollback restores the user bytes exactly', () => { + const user = `${JSON.stringify( + { + permissions: { allow: ['Bash(npm test)'] }, + hooks: { + PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: './.claude/hooks/no-rm.sh' }] }], + Stop: [{ hooks: [{ type: 'command', command: 'say done' }] }], + }, + }, + null, + 2, + )}\n`; + fs.mkdirSync(path.dirname(settingsAbs()), { recursive: true }); + fs.writeFileSync(settingsAbs(), user); + config({ claudeHooks: 'advisory' }); + installClients(['claude'], { root, mcp: MCP }); + const merged = JSON.parse(fs.readFileSync(settingsAbs(), 'utf8')); + expect(merged.permissions).toEqual({ allow: ['Bash(npm test)'] }); + expect(merged.hooks.PreToolUse[0]).toEqual({ matcher: 'Bash', hooks: [{ type: 'command', command: './.claude/hooks/no-rm.sh' }] }); + expect(merged.hooks.PreToolUse).toHaveLength(2); + expect(merged.hooks.Stop).toHaveLength(2); + uninstallClients(['claude'], { root, mcp: MCP }); + expect(fs.readFileSync(settingsAbs(), 'utf8')).toBe(user); + }); + + it('turning hooks off removes only Squad handlers on the next upgrade', () => { + const user = '{\n "model": "opus"\n}\n'; + fs.mkdirSync(path.dirname(settingsAbs()), { recursive: true }); + fs.writeFileSync(settingsAbs(), user); + config({ claudeHooks: 'advisory' }); + installClients(['claude'], { root, mcp: MCP }); + config({ claudeHooks: 'off' }); + installClients(['claude'], { root, mcp: MCP }); + expect(fs.readFileSync(settingsAbs(), 'utf8')).toBe(user); + }); + + it('rollback deletes a settings file that held only Squad hooks, and prunes .claude', () => { + config({ claudeHooks: 'advisory' }); + installClients(['claude'], { root, mcp: MCP }); + expect(fs.existsSync(settingsAbs())).toBe(true); + uninstallClients(['claude'], { root, mcp: MCP }); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + }); + + it('never matches a user handler, even one that also runs a `hook` subcommand', () => { + const userHandler = { type: 'command', command: 'tool', args: ['hook', 'pre-tool-use'] }; + expect(isSquadHookHandler(userHandler)).toBe(false); + const settings = { hooks: { PreToolUse: [{ hooks: [userHandler] }] } }; + expect(stripClaudeHooks(settings)).toBe(settings); + expect(stripClaudeHooks(mergeClaudeHooks(settings, MCP))).toEqual(settings); + }); + + it('refuses to clobber a malformed hooks value', () => { + expect(() => mergeClaudeHooks({ hooks: [] as unknown as Record }, MCP)).toThrow(/Refusing/); + expect(() => mergeClaudeHooks({ hooks: { Stop: 'x' } }, MCP)).toThrow(/Refusing/); + }); +}); + +describe('Claude hook runtime (advisory)', () => { + let root: string; + let env: ClaudeHookEnv; + const squad = () => path.join(root, '.squad'); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-hook-run-')); + fs.mkdirSync(path.join(squad(), 'decisions/inbox'), { recursive: true }); + fs.writeFileSync(path.join(squad(), 'config.json'), '{ "claudeHooks": "advisory" }\n'); + fs.writeFileSync(path.join(squad(), 'team.md'), '# Team\n'); + fs.writeFileSync(path.join(squad(), 'routing.md'), '| Work Type | Route To | Examples |\n|---|---|---|\n| API | dallas | REST |\n'); + for (const [id, role] of [['dallas', 'Backend Dev'], ['lambert', 'Backend Dev'], ['ripley', 'Lead']]) { + fs.mkdirSync(path.join(squad(), 'agents', id!), { recursive: true }); + fs.writeFileSync(path.join(squad(), 'agents', id!, 'charter.md'), `# ${id} — ${role}\n\n## Identity\n- **Role:** ${role}\n`); + } + writeLockouts(squad(), reject(emptyLockoutStore())); + env = { root, teamAbs: squad(), projectAbs: squad(), version: '9.9.9' }; + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + const edit = (agent: string | undefined, file: string) => + runClaudeHook('PreToolUse', { hook_event_name: 'PreToolUse', agent_type: agent, tool_name: 'Edit', tool_input: { file_path: path.join(root, file) }, cwd: root }, env); + + it('PreToolUse warns when a locked-out member edits a subject file, and never denies', () => { + const out = edit('dallas', 'src/auth/login.ts')!; + expect(out.systemMessage).toMatch(/dallas is locked out of issue-42/); + expect(out.hookSpecificOutput).toMatchObject({ hookEventName: 'PreToolUse' }); + expect(JSON.stringify(out)).not.toMatch(/permissionDecision|"deny"/); + }); + + it('PreToolUse is silent for other paths, other members, the coordinator, and after approval', () => { + expect(edit('dallas', 'src/billing/pay.ts')).toBeNull(); + expect(edit('lambert', 'src/auth/login.ts')).toBeNull(); + expect(edit('squad', 'src/auth/login.ts')).toBeNull(); + expect(edit(undefined, 'src/auth/login.ts')).toBeNull(); + let s = readLockouts(squad()); + s = approveRevision(submitRevision(assignRevision(s, { subject: 'issue-42', successor: 'lambert' }), { subject: 'issue-42', author: 'lambert' }), { subject: 'issue-42', reviewer: 'ripley' }); + writeLockouts(squad(), s); + expect(edit('dallas', 'src/auth/login.ts')).toBeNull(); + }); + + it('PreToolUse matches through a symlinked project path, including a file that does not exist yet', () => { + const link = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-hook-link-')), 'repo'); + fs.symlinkSync(root, link); + try { + const real = fs.realpathSync(root); + const input = { agent_type: 'dallas', tool_name: 'Write', tool_input: { file_path: path.join(link, 'src/auth/new/file.ts') }, cwd: link }; + expect(runClaudeHook('PreToolUse', input, { ...env, root: real })!.systemMessage).toContain('src/auth/new/file.ts'); + expect(runClaudeHook('PreToolUse', { ...input, tool_input: { file_path: path.join(os.tmpdir(), 'elsewhere.ts') } }, { ...env, root: real })).toBeNull(); + } finally { + fs.rmSync(path.dirname(link), { recursive: true, force: true }); + } + }); + + it('PreToolUse is silent after an authority override', () => { + writeLockouts(squad(), overrideLockout(readLockouts(squad()), { subject: 'issue-42', author: 'dallas', by: 'user', reason: 'hotfix', authorities: ['user'] })); + expect(edit('dallas', 'src/auth/login.ts')).toBeNull(); + }); + + it('does nothing when hooks are off, even if the handlers are still installed', () => { + fs.writeFileSync(path.join(squad(), 'config.json'), '{}\n'); + expect(edit('dallas', 'src/auth/login.ts')).toBeNull(); + expect(runClaudeHook('SessionStart', { agent_type: 'squad' }, env)).toBeNull(); + }); + + it('SessionStart for the coordinator injects health, roster, routing and active lockouts', () => { + const ctx = runClaudeHook('SessionStart', { agent_type: 'squad', source: 'startup' }, env)!.hookSpecificOutput!.additionalContext; + expect(ctx).toContain('squad-state 9.9.9'); + expect(ctx).toMatch(/Roster: dallas \(Backend Dev\).*ripley \(Lead\)/); + expect(ctx).toContain('"API" → dallas'); + expect(ctx).toMatch(/issue-42 \(author-locked\) on src\/auth\/\*\*: dallas locked out after ripley's REJECT/); + expect(runClaudeHook('SessionStart', { source: 'startup' }, env)).toBeNull(); + expect(runClaudeHook('SubagentStart', { agent_type: 'squad' }, env)!.hookSpecificOutput!.hookEventName).toBe('SubagentStart'); + }); + + it('SessionStart reports a missing team instead of injecting context', () => { + fs.rmSync(path.join(squad(), 'team.md')); + expect(runClaudeHook('SessionStart', { agent_type: 'squad' }, env)).toEqual({ systemMessage: expect.stringMatching(/no team found/) }); + }); + + it('PostToolUse on a member spawn adds a coordination-handoff result envelope', () => { + const out = runClaudeHook('PostToolUse', { agent_type: 'squad', tool_name: 'Agent', tool_use_id: 'toolu_1', tool_input: { subagent_type: 'lambert', description: 'Fix auth tests' } }, env)!; + const ctx = out.hookSpecificOutput!.additionalContext; + expect(ctx).toContain('"profile":"squad-coordination-handoff/v0.1"'); + expect(ctx).toContain('"type":"result"'); + expect(ctx).toContain('"sender":"lambert"'); + // The tool call's free-text description is not reflected back into coordinator context. + expect(ctx).not.toContain('Fix auth tests'); + expect(ctx).not.toContain('"task"'); + expect(runClaudeHook('PostToolUse', { tool_name: 'Agent', tool_input: { subagent_type: 'Explore' } }, env)).toBeNull(); + }); + + it('Stop asks for Scribe once when the decisions inbox is non-empty, and reports an open Ralph circuit', () => { + expect(runClaudeHook('Stop', { agent_type: 'squad', stop_hook_active: false }, env)).toBeNull(); + fs.writeFileSync(path.join(squad(), 'decisions/inbox/dallas-auth.md'), '# decision\n'); + const now = Date.parse(AT); + fs.writeFileSync( + path.join(squad(), 'ralph-circuit-breaker.json'), + JSON.stringify({ state: 'open', preferredModel: 'claude-sonnet-5', fallbackChain: ['gpt-5.4-mini'], currentFallbackIndex: 0, cooldownMinutes: 10, openedAt: new Date(now - 60_000).toISOString() }), + ); + const out = runClaudeHook('Stop', { agent_type: 'squad', stop_hook_active: false }, env, now)!; + expect(out.hookSpecificOutput!.additionalContext).toMatch(/1 unmerged entry.*Spawn Scribe/); + expect(out.systemMessage).toMatch(/circuit breaker is OPEN.*gpt-5\.4-mini/); + expect(runClaudeHook('Stop', { agent_type: 'squad', stop_hook_active: true }, env, now)).toBeNull(); + // Cooldown over: the breaker no longer reports open. + expect(runClaudeHook('Stop', { agent_type: 'squad', stop_hook_active: false }, env, now + 11 * 60_000)!.systemMessage).toBeUndefined(); + }); +}); + +describe('squad lockout CLI', () => { + let root: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-lockout-cli-')); + execFileSync('git', ['init', '-q'], { cwd: root }); + for (const id of ['dallas', 'lambert', 'ripley']) { + fs.mkdirSync(path.join(root, '.squad/agents', id), { recursive: true }); + fs.writeFileSync(path.join(root, '.squad/agents', id, 'charter.md'), `# ${id}\n`); + } + fs.writeFileSync(path.join(root, '.squad/team.md'), '# Team\n'); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('records reject, assign, submit and approve; refuses an invalid transition', async () => { + const { runLockoutCommand } = await import('../packages/squad-cli/src/cli/commands/lockout.js'); + const run = (...a: string[]) => runLockoutCommand(root, a); + expect(await run('reject', 'pr-7', '--author', 'dallas', '--reviewer', 'ripley', '--reason', 'no tests', '--paths', 'src/**')).toBe(0); + expect(await run('assign', 'pr-7', '--successor', 'dallas')).toBe(1); + expect(await run('assign', 'pr-7', '--successor', 'lambert')).toBe(0); + expect(await run('submit', 'pr-7', '--author', 'lambert')).toBe(0); + expect(await run('approve', 'pr-7', '--reviewer', 'ripley')).toBe(0); + const rec = readLockouts(path.join(root, '.squad')).subjects[0]!; + expect(rec).toMatchObject({ subject: 'pr-7', state: 'cleared', lockedAuthors: ['dallas'], successor: 'lambert', paths: ['src/**'] }); + }); +}); diff --git a/test/claude-members.test.ts b/test/claude-members.test.ts new file mode 100644 index 000000000..10c016baf --- /dev/null +++ b/test/claude-members.test.ts @@ -0,0 +1,232 @@ +/** + * Claude Code member subagents (#2442 Phase 2): charters compile to `.claude/agents/.md`. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + installClients, + uninstallClients, +} from '../packages/squad-sdk/src/client/adapters/index.js'; +import { + parseClaudeHostSettings, + renderClaudeMember, +} from '../packages/squad-sdk/src/client/adapters/claude-members.js'; + +const NOTE = ''; +const MCP = { command: 'squad', args: ['state-mcp'] }; + +function charter(opts: { model?: string; effort?: string; tier?: string; extension?: string; purpose?: string } = {}): string { + return [ + '# Dallas — Backend Dev', + '', + '## Identity', + '- **ID:** `dallas`', + ...(opts.purpose ? [`- **Purpose:** ${opts.purpose}`] : []), + '', + '## Accountable Responsibilities', + '- APIs', + '', + '## Non-Responsibilities', + '- UI', + '', + '## Collaboration and Review Authority', + '- Reviews API changes', + '', + '## Model', + `- **Preferred:** ${opts.model ?? 'auto'}`, + `- **Reasoning Effort:** ${opts.effort ?? 'auto'}`, + `- **Context Tier:** ${opts.tier ?? 'auto'}`, + ...(opts.extension ? ['', opts.extension] : []), + '', + ].join('\n'); +} + +function frontmatter(md: string): Record { + const block = md.match(/^---\n([\s\S]*?)\n---\n/)![1]!; + return Object.fromEntries(block.split('\n').map((l) => [l.slice(0, l.indexOf(':')), l.slice(l.indexOf(':') + 1).trim()])); +} + +describe('renderClaudeMember: charter → Claude subagent frontmatter', () => { + it('maps a Claude model and reasoning effort, and names the member by id', () => { + const fm = frontmatter(renderClaudeMember('dallas', charter({ model: '`sonnet`', effort: 'high' }), [], '.squad', NOTE)); + expect(fm).toMatchObject({ name: 'dallas', model: 'sonnet', effort: 'high' }); + }); + + it('omits model and effort for auto (charter §6: auto is never a literal override)', () => { + const md = renderClaudeMember('dallas', charter(), [], '.squad', NOTE); + const fm = frontmatter(md); + expect(fm.model).toBeUndefined(); + expect(fm.effort).toBeUndefined(); + expect(md).not.toContain('not projected'); + }); + + it('reports a non-Claude model and an unapplied long_context tier instead of dropping them silently', () => { + const md = renderClaudeMember('dallas', charter({ model: 'gpt-5', tier: 'long_context' }), [], '.squad', NOTE); + expect(frontmatter(md).model).toBeUndefined(); + expect(md).toMatch(/not projected to Claude Code: Model `gpt-5` .*Context Tier `long_context`/); + }); + + it('keeps an explicit long-context Claude model and does not report the tier', () => { + const md = renderClaudeMember('dallas', charter({ model: 'sonnet[1m]', tier: 'long_context' }), [], '.squad', NOTE); + expect(frontmatter(md).model).toBe('sonnet[1m]'); + expect(md).not.toContain('long_context'); + }); + + it('builds a single-line description from Purpose and routing ownership', () => { + const md = renderClaudeMember( + 'dallas', + charter({ purpose: 'Owns the "API" layer.' }), + [{ workType: 'Backend', routeTo: 'dallas' }, { workType: 'UI', routeTo: 'lambert' }], + '.squad', + NOTE, + ); + const description = JSON.parse(frontmatter(md).description!); + expect(description).toBe('Owns the "API" layer. Owns: Backend. Spawned by the Squad coordinator.'); + }); + + it('points the subagent at its charter and history, and forbids nested dispatch', () => { + const md = renderClaudeMember('dallas', charter(), [], 'team/.squad', NOTE); + expect(md).toContain('`team/.squad/agents/dallas/charter.md`'); + expect(md).toContain('`team/.squad/agents/dallas/history.md`'); + expect(md).toContain('do not spawn subagents of your own'); + expect(md).toContain(NOTE); + }); +}); + +describe('X-squad-host-claude charter extension', () => { + it('projects Tools and Isolation (case-insensitive heading, backticks allowed)', () => { + const ext = '## x-squad-host-claude\n- **Tools:** `Read`, Grep, Glob, mcp__squad_state__squad_state_read\n- **Isolation:** worktree'; + const fm = frontmatter(renderClaudeMember('dallas', charter({ extension: ext }), [], '.squad', NOTE)); + expect(fm.tools).toBe('Read, Grep, Glob, mcp__squad_state__squad_state_read'); + expect(fm.isolation).toBe('worktree'); + }); + + it('rejects values that could escape the frontmatter and reports them', () => { + const settings = parseClaudeHostSettings( + charter({ extension: '## X-squad-host-claude\n- **Tools:** Read, Bash\nmodel: opus\n- **Isolation:** container' }), + ); + // The stray `model: opus` line is not a field; it cannot reach the frontmatter. + expect(settings.tools).toEqual(['Read', 'Bash']); + expect(settings.isolation).toBeUndefined(); + expect(settings.rejected).toEqual(['Isolation `container` (only `worktree` is supported)']); + expect(parseClaudeHostSettings(charter({ extension: '## X-squad-host-claude\n- **Tools:** Read, "x": y' })).rejected[0]).toMatch(/^Tools/); + }); + + it('ignores the extension of another host and fields after the next section', () => { + const ext = '## X-squad-host-kiro\n- **Tools:** Read\n\n## X-squad-host-claude\n- **Isolation:** worktree\n\n## X-other-thing\n- **Tools:** Bash'; + const settings = parseClaudeHostSettings(charter({ extension: ext })); + expect(settings).toEqual({ isolation: 'worktree', rejected: [] }); + }); +}); + +describe('Claude member projection: apply, skip user files, prune, rollback', () => { + let root: string; + const writeCharter = (id: string, body = charter()) => { + fs.mkdirSync(path.join(root, '.squad/agents', id), { recursive: true }); + fs.writeFileSync(path.join(root, '.squad/agents', id, 'charter.md'), body); + }; + const agentFile = (id: string) => path.join(root, '.claude/agents', `${id}.md`); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-members-')); + writeCharter('dallas', charter({ model: 'opus', effort: 'max' })); + writeCharter('scribe'); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('writes one subagent per member charter next to the coordinator', () => { + const results = installClients(['claude'], { root, mcp: MCP }); + expect(results.find((r) => r.path === '.claude/agents/dallas.md')?.action).toBe('created'); + expect(fs.readdirSync(path.join(root, '.claude/agents')).sort()).toEqual(['dallas.md', 'scribe.md', 'squad.md']); + expect(frontmatter(fs.readFileSync(agentFile('dallas'), 'utf8'))).toMatchObject({ model: 'opus', effort: 'max' }); + expect(installClients(['claude'], { root, mcp: MCP }).every((r) => r.action === 'unchanged')).toBe(true); + }); + + it('never projects a member named like the coordinator', () => { + writeCharter('squad'); + installClients(['claude'], { root, mcp: MCP }); + expect(fs.readFileSync(agentFile('squad'), 'utf8')).toContain('name: squad\ndescription: "Squad coordinator'); + }); + + it('skips and reports a user-authored file at a member path, on install and on rollback', () => { + fs.mkdirSync(path.join(root, '.claude/agents'), { recursive: true }); + const mine = '---\nname: dallas\ndescription: my own\n---\nHand-written.\n'; + fs.writeFileSync(agentFile('dallas'), mine); + const results = installClients(['claude'], { root, mcp: MCP }); + expect(results.find((r) => r.path === '.claude/agents/dallas.md')).toMatchObject({ action: 'skipped' }); + expect(fs.readFileSync(agentFile('dallas'), 'utf8')).toBe(mine); + + const removed = uninstallClients(['claude'], { root, mcp: MCP }); + expect(removed.find((r) => r.path === '.claude/agents/dallas.md')).toMatchObject({ action: 'skipped' }); + expect(fs.readFileSync(agentFile('dallas'), 'utf8')).toBe(mine); + expect(fs.existsSync(agentFile('scribe'))).toBe(false); + }); + + it('removes the generated file of a member whose charter is gone, but not user files', () => { + installClients(['claude'], { root, mcp: MCP }); + fs.writeFileSync(agentFile('helper'), '---\nname: helper\ndescription: mine\n---\n'); + fs.rmSync(path.join(root, '.squad/agents/dallas'), { recursive: true }); + const results = installClients(['claude'], { root, mcp: MCP }); + expect(results.find((r) => r.path === '.claude/agents/dallas.md')?.action).toBe('removed'); + expect(fs.existsSync(agentFile('dallas'))).toBe(false); + expect(fs.existsSync(agentFile('helper'))).toBe(true); + }); + + it('rollback removes generated members even after their charters were deleted, and prunes .claude', () => { + installClients(['claude'], { root, mcp: MCP }); + fs.rmSync(path.join(root, '.squad/agents'), { recursive: true }); + uninstallClients(['claude'], { root, mcp: MCP }); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + }); + + it('honors a custom squadDir', () => { + fs.mkdirSync(path.join(root, 'team'), { recursive: true }); + fs.renameSync(path.join(root, '.squad'), path.join(root, 'team/.squad')); + installClients(['claude'], { root, mcp: MCP, squadDir: 'team/.squad' }); + expect(fs.readFileSync(agentFile('dallas'), 'utf8')).toContain('`team/.squad/agents/dallas/charter.md`'); + }); +}); + +describe('CLI: squad upgrade --client claude and squad cast write member subagents', () => { + let root: string; + const prev = process.env['SQUAD_NONINTERACTIVE']; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-cli-')); + process.env['SQUAD_NONINTERACTIVE'] = '1'; + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + afterEach(() => { + vi.restoreAllMocks(); + if (prev === undefined) delete process.env['SQUAD_NONINTERACTIVE']; + else process.env['SQUAD_NONINTERACTIVE'] = prev; + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('projects the built-ins, then a newly cast member', 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'); + await runInit(root, { includeWorkflows: false, includeVscodeDefault: false }); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + + await runUpgrade(root, { clients: ['copilot', 'claude'] }); + const agents = () => fs.readdirSync(path.join(root, '.claude/agents')).sort(); + expect(agents()).toEqual(expect.arrayContaining(['fact-checker.md', 'rai.md', 'ralph.md', 'scribe.md', 'squad.md'])); + expect(agents()).not.toContain('dallas.md'); + + await runCastAdd(root, { name: 'Dallas', role: 'Backend Dev' }); + expect(agents()).toContain('dallas.md'); + expect(fs.readFileSync(path.join(root, '.claude/agents/dallas.md'), 'utf8')).toMatch(/^---\nname: dallas\n/); + }, 60_000); + + it('squad cast writes nothing under .claude when claude is not configured', async () => { + const { runInit } = await import('../packages/squad-cli/src/cli/core/init.js'); + const { runCastAdd } = await import('../packages/squad-cli/src/cli/commands/cast.js'); + await runInit(root, { includeWorkflows: false, includeVscodeDefault: false }); + await runCastAdd(root, { name: 'Dallas', role: 'Backend Dev' }); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + }, 60_000); +}); diff --git a/test/claude-projection-v2-rollback.test.ts b/test/claude-projection-v2-rollback.test.ts new file mode 100644 index 000000000..d7417d800 --- /dev/null +++ b/test/claude-projection-v2-rollback.test.ts @@ -0,0 +1,145 @@ +/** + * Claude projection v2 (#2442): one version bump for member subagents, skills and the opt-in + * `settings.json` hooks, and byte-preserving rollback for every file v2 writes (harness-adapters §15.7). + * + * Each rollback test snapshots the whole tree before install and requires the exact same bytes after + * install → upgrade → rollback: no generated file left behind, no user byte changed. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + CLAUDE_PROJECTION_VERSION, + installClients, + uninstallClients, +} from '../packages/squad-sdk/src/client/adapters/index.js'; + +const MCP = { command: 'squad', args: ['state-mcp'] }; +const V1_NOTE = ''; + +let root: string; + +function write(rel: string, content: string): void { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); +} + +/** Every file under root, relative path → bytes (base64), plus the directory set. */ +function snapshot(): { files: Record; dirs: string[] } { + const files: Record = {}; + const dirs: string[] = []; + 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()) { + dirs.push(rel); + walk(abs); + } else files[rel] = fs.readFileSync(abs).toString('base64'); + } + }; + walk(root); + return { files, dirs: dirs.sort() }; +} + +/** Team inputs that make v2 project every kind of file: members, template skills and hooks. */ +function seedTeam(): void { + write('.squad/team.md', '# Team\n'); + write('.squad/agents/dallas/charter.md', '# Dallas — Backend Dev\n\n## Identity\n- **ID:** `dallas`\n\n## Model\n- **Preferred:** sonnet\n'); + write('.squad/agents/scribe/charter.md', '# Scribe\n\n## Identity\n- **ID:** `scribe`\n'); + write('.squad/templates/skills/git-workflow/SKILL.md', '---\nname: "git-workflow"\ndescription: "Branching"\n---\n\nUse branches.\n'); + write('.squad/templates/skills/distributed-mesh/SKILL.md', '---\nname: "distributed-mesh"\ndescription: "Mesh"\n---\n\nSync.\n'); + write('.squad/templates/skills/distributed-mesh/sync-mesh.sh', 'echo hi\n'); + write('.squad/config.json', `${JSON.stringify({ claudeHooks: 'advisory' }, null, 2)}\n`); +} + +function generatedMarkdown(): string[] { + return Object.keys(snapshot().files).filter((f) => f.startsWith('.claude/') && f.endsWith('.md')); +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-v2-')); + seedTeam(); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('Claude projection v2: version', () => { + it('is bumped once, to v2', () => { + expect(CLAUDE_PROJECTION_VERSION).toBe(2); + }); + + it('stamps the coordinator, every member and every skill', () => { + installClients(['claude'], { root, mcp: MCP }); + const md = generatedMarkdown(); + expect(md).toEqual(expect.arrayContaining(['.claude/agents/squad.md', '.claude/agents/dallas.md', '.claude/agents/scribe.md', '.claude/skills/git-workflow/SKILL.md'])); + for (const f of md) expect(fs.readFileSync(path.join(root, f), 'utf8'), f).toContain('Claude projection v2.'); + }); + + it('upgrades v1 files in place: the old note still marks them as Squad-owned', () => { + write('.claude/agents/squad.md', `---\nname: squad\n---\n${V1_NOTE}\n\nold coordinator\n`); + write('.claude/agents/dallas.md', `---\nname: dallas\n---\n${V1_NOTE}\n\nold member\n`); + const results = installClients(['claude'], { root, mcp: MCP }); + for (const p of ['.claude/agents/squad.md', '.claude/agents/dallas.md']) { + expect(results.find((r) => r.path === p)?.action, p).toBe('updated'); + expect(fs.readFileSync(path.join(root, p), 'utf8')).toContain('Claude projection v2.'); + } + uninstallClients(['claude'], { root, mcp: MCP }); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + }); +}); + +describe('Claude projection v2: byte-preserving rollback (§15.7)', () => { + it('a fresh repo returns to exactly its pre-install tree', () => { + const before = snapshot(); + installClients(['claude'], { root, mcp: MCP }); + installClients(['claude'], { root, mcp: MCP }); // upgrade over itself + expect(fs.existsSync(path.join(root, '.claude/settings.json'))).toBe(true); + uninstallClients(['claude'], { root, mcp: MCP }); + expect(snapshot()).toEqual(before); + }); + + it('keeps every user byte in each file v2 shares with the user', () => { + write('CLAUDE.md', '# My project\r\n\r\nUse pnpm.'); // CRLF, no trailing newline + write('.gitignore', 'node_modules/\n.env\n'); + write('.mcp.json', `${JSON.stringify({ mcpServers: { github: { command: 'gh-mcp', args: [] } } }, null, 2)}\n`); + write( + '.claude/settings.json', + `${JSON.stringify( + { + permissions: { allow: ['Bash(npm test:*)'] }, + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'audit.sh' }] }] }, + }, + null, + 2, + )}\n`, + ); + // User-authored agents and skills, including ones at paths v2 would generate. + write('.claude/agents/reviewer.md', '---\nname: reviewer\n---\nMine.\n'); + write('.claude/agents/dallas.md', '---\nname: dallas\n---\nHand-written Dallas.\n'); + write('.claude/skills/mine/SKILL.md', '---\nname: mine\n---\nMy skill.\n'); + write('.claude/skills/git-workflow/SKILL.md', '---\nname: git-workflow\n---\nMy workflow.\n'); + const before = snapshot(); + + const install = installClients(['claude'], { root, mcp: MCP }); + for (const p of ['.claude/agents/dallas.md', '.claude/skills/git-workflow/SKILL.md']) { + expect(install.find((r) => r.path === p)?.action, p).toBe('skipped'); + } + expect(fs.readFileSync(path.join(root, '.claude/agents/scribe.md'), 'utf8')).toContain('Claude projection v2.'); + installClients(['claude'], { root, mcp: MCP }); + + uninstallClients(['claude'], { root, mcp: MCP }); + expect(snapshot()).toEqual(before); + }); + + it('turning hooks off on upgrade, then rolling back, still restores the original bytes', () => { + write('.claude/settings.json', '{\n "model": "opus"\n}\n'); + const before = snapshot(); + installClients(['claude'], { root, mcp: MCP }); + write('.squad/config.json', `${JSON.stringify({ claudeHooks: 'off' }, null, 2)}\n`); + installClients(['claude'], { root, mcp: MCP }); + write('.squad/config.json', `${JSON.stringify({ claudeHooks: 'advisory' }, null, 2)}\n`); + uninstallClients(['claude'], { root, mcp: MCP }); + expect(snapshot()).toEqual(before); + }); +}); diff --git a/test/claude-skills.test.ts b/test/claude-skills.test.ts new file mode 100644 index 000000000..ff90227a1 --- /dev/null +++ b/test/claude-skills.test.ts @@ -0,0 +1,111 @@ +/** + * Claude Code skills projection and coordinator slimming (#2442 Phase 4). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + CLIENT_IDS, + buildCoordinatorInstructions, + installClients, + uninstallClients, +} from '../packages/squad-sdk/src/client/adapters/index.js'; +import { claudeCoordinatorSkills } from '../packages/squad-sdk/src/client/adapters/coordinator.js'; + +const MCP = { command: 'squad', args: ['state-mcp'] }; +const SKILLS = ['squad-coordinator-init-mode', 'squad-coordinator-ralph-mode', 'squad-coordinator-ceremonies', 'squad-coordinator-issues-mode', 'squad-coordinator-prd-mode']; + +describe('Claude coordinator slimming', () => { + const claude = buildCoordinatorInstructions('claude'); + + it('replaces each on-demand item with a stub that names its skill', () => { + expect(claudeCoordinatorSkills().map((k) => k.name)).toEqual(SKILLS); + for (const k of SKILLS) expect(claude).toContain(`\`${k}\``); + // The loop body itself lives only in the skill. + expect(claude).not.toContain('**Scan** with the `gh` CLI'); + expect(Buffer.byteLength(claude)).toBeLessThan(Buffer.byteLength(buildCoordinatorInstructions('kiro'))); + }); + + it('keeps the rules that must hold before a skill is loaded', () => { + expect(claude).toMatch(/NEVER with glob\/grep\/search tools/); + expect(claude).toMatch(/NEVER overwrite or re-cast it/); + expect(claude).toMatch(/never delegate the loop itself/); + }); + + it('moves every item verbatim: the skill bodies match the full digest other hosts load', () => { + const kiro = buildCoordinatorInstructions('kiro'); + for (const k of claudeCoordinatorSkills()) { + const item = k.body.split('\n\n').slice(2).join('\n\n').trimEnd(); + expect(kiro, k.name).toContain(item); + expect(claude, k.name).not.toContain(item); + } + }); + + it('leaves every other host digest untouched', () => { + for (const id of CLIENT_IDS.filter((h) => h !== 'claude')) { + const t = buildCoordinatorInstructions(id); + expect(t, id).toContain('**Scan** with the `gh` CLI'); + expect(t, id).not.toContain('squad-coordinator-'); + } + }); +}); + +describe('Claude skills projection', () => { + let root: string; + const skillFile = (name: string) => path.join(root, '.claude/skills', name, 'SKILL.md'); + const template = (name: string, body = `---\nname: "${name}"\ndescription: "Template ${name}"\n---\n\n## Context\n\nUse ${name}.\n`) => { + fs.mkdirSync(path.join(root, '.squad/templates/skills', name), { recursive: true }); + fs.writeFileSync(path.join(root, '.squad/templates/skills', name, 'SKILL.md'), body); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-skills-')); + template('git-workflow'); + template('distributed-mesh'); + fs.writeFileSync(path.join(root, '.squad/templates/skills/distributed-mesh/sync-mesh.sh'), 'echo hi\n'); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('projects template skills with the generated note after the frontmatter, plus the coordinator skills', () => { + installClients(['claude'], { root, mcp: MCP }); + expect(fs.readdirSync(path.join(root, '.claude/skills')).sort()).toEqual(['distributed-mesh', 'git-workflow', ...SKILLS].sort()); + const git = fs.readFileSync(skillFile('git-workflow'), 'utf8'); + expect(git).toMatch(/^---\nname: "git-workflow"\ndescription: "Template git-workflow"\n---\n\n`); + it('upsertBlock appends after one blank line and keeps a missing final newline missing', () => { + expect(upsertBlock('a', 'x')).toBe(`a\n\n${BLOCK_BEGIN}\nx\n`); + expect(upsertBlock('a\n', 'x')).toBe(`a\n\n${BLOCK_BEGIN}\nx\n\n`); + }); + + it('removeBlock restores the exact original bytes however the file ended (§15.7)', () => { + for (const original of ['', 'a', 'a\n', 'a\n\n', 'a\n\n\n', '# T\n\nbody', '\n', 'x\n ']) { + expect(removeBlock(upsertBlock(original, 'x')), JSON.stringify(original)).toBe(original); + expect(removeBlock(upsertBlock(upsertBlock(original, 'x'), 'x-v2')), JSON.stringify(original)).toBe(original); + } }); it('applyProjection reports created then updated', () => { diff --git a/test/speed-gates.test.ts b/test/speed-gates.test.ts index 6921836d6..9c8efa452 100644 --- a/test/speed-gates.test.ts +++ b/test/speed-gates.test.ts @@ -43,8 +43,8 @@ describe('Speed: --help is scannable', { timeout: 30_000 }, () => { const output = harness.captureFrame(); const lines = output.split('\n').filter(l => l.trim()); // Budget grew to accommodate `sync` + `state-mcp` commands. Hard cap - // keeps us honest if help text starts ballooning again. - expect(lines.length).toBeLessThanOrEqual(150); + // keeps us honest if help text starts ballooning again. +1 for `lockout`. + expect(lines.length).toBeLessThanOrEqual(151); }); it('first 5 lines tell user what to do next', async () => { diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts index e72570427..267ca1da8 100644 --- a/test/squad-20-all-scenarios.e2e.test.ts +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -32,6 +32,7 @@ import { assertPortableClients, getClientAdapter, buildCoordinatorInstructions, + claudeCoordinatorSkills, createClientAdapter, installClients, uninstallClients, @@ -1253,9 +1254,17 @@ describe('coordinator prompt (found with real models)', () => { expect(text).toMatch(/state tools? reject|reject writes/i); }); }); +/** + * What the coordinator follows once it loads the guidance it needs. Claude Code keeps Init Mode and + * the other on-demand sections in skills (#2442 Phase 4), so its text includes those skill bodies. + */ +function loadedCoordinatorText(id: ClientId): string { + const text = buildCoordinatorInstructions(id); + return id === 'claude' ? [text, ...claudeCoordinatorSkills().map((k) => k.body)].join('\n\n') : text; +} describe('field findings from the live demo: Init Mode, scratch files, casting registry', () => { it.each(CLIENT_IDS)('%s: non-interactive Init Mode casts immediately and registers the roster with squad cast --name', (id) => { - const text = buildCoordinatorInstructions(id); + const text = loadedCoordinatorText(id); // live finding: `claude -p "... proceed ..."` still paused to ask for confirmation expect(text).toMatch(/Auto-confirm \(default\)/); expect(text).toMatch(/Do NOT stop to ask questions in Init Mode/); @@ -1267,7 +1276,7 @@ describe('field findings from the live demo: Init Mode, scratch files, casting r }); it.each(CLIENT_IDS)('%s: keeps scratch files inside the workspace', (id) => { - const text = buildCoordinatorInstructions(id); + const text = loadedCoordinatorText(id); // live finding: OpenCode rejected a subagent call that touched OS %TEMP% expect(text).toMatch(/Scratch space/); expect(text).toMatch(/never write outside the workspace directory/); @@ -1275,7 +1284,7 @@ describe('field findings from the live demo: Init Mode, scratch files, casting r }); it.each(CLIENT_IDS)('%s: casts through the CLI and forbids hand-written casting files', (id) => { - const text = buildCoordinatorInstructions(id); + const text = loadedCoordinatorText(id); // live finding: LLM-handwritten casting/registry.json broke `squad upgrade` expect(text).toMatch(/npx squad cast --name --role/); expect(text).toMatch(/NEVER hand-write or edit anything under .{0,20}casting\//); diff --git a/test/squad-20-host-feature-matrix.test.ts b/test/squad-20-host-feature-matrix.test.ts index 9d6af2e35..00c194951 100644 --- a/test/squad-20-host-feature-matrix.test.ts +++ b/test/squad-20-host-feature-matrix.test.ts @@ -170,7 +170,7 @@ describe('host-feature-matrix.json integrity', () => { const gapName = title!.slice(PREFIX.length); expect(src.includes(`['${gapName}', `), `${e.featureId}: gap "${gapName}" is not registered in ${file}`).toBe(true); expect(src.includes(`it.fails(\`${PREFIX}\${name} [\${id}]\``), `${file}: per-host it.fails template changed; update the matrix`).toBe(true); - expect(src.includes('for (const id of CLIENT_IDS) {\n it.fails('), `${file}: per-host it.fails must iterate CLIENT_IDS`).toBe(true); + expect(src.includes('for (const id of CLIENT_IDS.filter((h) => !closed.includes(h))) {\n it.fails('), `${file}: per-host it.fails must iterate CLIENT_IDS minus the gap's closed hosts`).toBe(true); for (const h of hosts) expect(CLIENT_IDS, `${e.featureId}: host ${h} is not a client id`).toContain(h); } else { expect(src.includes(title!), `${e.featureId}: it.fails title "${title}" not found in ${file}`).toBe(true);