diff --git a/.changeset/plugin-icon-human-members.md b/.changeset/plugin-icon-human-members.md new file mode 100644 index 000000000..63a958591 --- /dev/null +++ b/.changeset/plugin-icon-human-members.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": patch +"@bradygaster/squad-cli": patch +--- + +The Claude Code plugin ships an icon, so `claude plugin validate` reports no warnings, and `.squad/templates/human-members.md`, listed in the template index but missing, ships again with the human sign-off gate. diff --git a/.gitattributes b/.gitattributes index 6285a8b48..69b38009d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -68,3 +68,7 @@ scripts/release/manifest.schema.json text eol=lf # no extension. Pin LF so Windows checkouts neither break it nor drift (#2443). plugins/** text eol=lf .claude-plugin/** text eol=lf +# Images are binary, including inside plugins/** above (the plugin icon): never convert line endings. +*.png binary -eol +*.jpg binary -eol +*.gif binary -eol diff --git a/.squad-templates/human-members.md b/.squad-templates/human-members.md new file mode 100644 index 000000000..e71f38eaf --- /dev/null +++ b/.squad-templates/human-members.md @@ -0,0 +1,83 @@ +# Human Members + +Reference for adding human team members to the Squad roster. + +## Triggers + +The coordinator enters the human-member flow when: + +- User says **"add {name} as {role}"** or **"add a human for {domain}"** +- User says **"make {name} a reviewer"** +- Routing detects a human member as the target for work + +## Comparison: AI Agent vs. Human Member + +| | AI Agent | Human Member | +|---|----------|-------------| +| Badge | Role-specific emoji | 👤 Human | +| Charter | ✅ | ❌ | +| History | ✅ | ❌ | +| Spawned as sub-agent | ✅ | ❌ | +| Can review work | ✅ | ✅ | +| Casting (fictional name) | ✅ | ❌ (real name) | + +## Adding a Human Member + +Follow these steps: + +1. **Use real name** — no casting. Badge: 👤 Human. +2. **Add to team.md roster:** + ```markdown + | {Name} | {Role} | — | 👤 Human | + ``` +3. **Add routing entries to routing.md:** + ```markdown + | {domain} | {Name} 👤 | {example tasks} | + ``` +4. **Do not create** `charter.md` or `history.md` for humans. +5. **Do not add** to casting registry. + +## Routing to a Human + +When work routes to a human member, the coordinator pauses. It never does that work itself or hands it to an AI member. + +**Sign-off gates.** When `team.md`, `routing.md` or a decision says a human must approve or sign off on a kind of work (a release, version bump, tag, publish, merge or deploy), a request to do that work is not the sign-off. Before any gated step the coordinator names the rule and the human, asks whether the user is that human or has their explicit sign-off, proceeds only on an explicit yes, and records it in the decisions inbox. In a non-interactive run it stops at the gate and reports what is waiting and for whom. + + +1. **Present the work to the user:** + ``` + "{Name} needs to review this. [Context about what needs review]" + ``` +2. **Non-dependent work continues immediately** — other agents proceed with unrelated tasks. +3. **Stale reminder** after >1 turn without response: + ``` + "📌 Still waiting on {Name} for {thing}." + ``` + +## Reviewing as a Human + +- Humans can serve as reviewers in the review process. +- **Rejection lockout applies normally** — original author is locked out when reviewer rejects. +- User relays the human's verdict to the coordinator ("approved" or "rejected with changes"). + +## Decision Framework: When to Add a Human Member + +| Scenario | Add to roster? | Why | +|----------|---------------|-----| +| Approves architecture decisions before implementation | ✅ Yes | Decision gate — team routes and waits | +| Reviews all docs PRs as standing reviewer | ✅ Yes | Recurring review gate | +| Occasionally reviews PRs when tagged | ❌ No | Use @mention on the PR | +| Files issues and contributes code | ❌ No | Normal GitHub collaboration | +| Makes final ship/no-ship call | ✅ Yes | Approval gate | + +**Litmus test:** If you want agents to *stop and wait* for someone's input before proceeding on a specific type of work, add them. If they review asynchronously via GitHub, don't bother. + +**Who is driving:** `git config user.name` tells the team who is probably at the keyboard. It is a hint, not a sign-off: for a gated step the coordinator still asks. Adding yourself to the roster is optional; it formalizes routing and tracking. + +## Removing a Human + +Same as removing any member: + +1. Move entry to alumni section in team.md. +2. Remove from active roster. +3. Update routing.md to remove all routing entries. diff --git a/CHANGELOG.md b/CHANGELOG.md index ab03dd27c..83e0f6e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,8 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o - `squad doctor` flags machine-specific launch commands and a missing launcher. Run `squad upgrade` once to fix an existing repository. - **Coordinators wait for a human's sign-off.** When `team.md` or `routing.md` says a human must sign off on work (a release, tag, publish, merge or deploy), the Claude, Kiro, OpenCode, Cursor, Codex and Antigravity coordinators no longer treat the request itself as the sign-off. They name the rule and the human, ask whether you are that human or have their sign-off, go ahead only on an explicit yes, and record it in the decisions inbox. In a non-interactive run they stop at the gate. Seen in a live test: asked to "cut the 1.0.0 release", the Claude coordinator used to bump, tag and run `npm publish` itself. - `squad_state` works when the team folder is reached through a symlink, such as macOS `/tmp` (really `/private/tmp`) or a linked team root. Squad stores the folder's resolved path but was given paths in the original spelling, so it refused reads and writes there with "Path traversal blocked". Paths that really leave the team folder, including through a symlink inside it, are still refused. +- The Claude Code plugin has an icon (the Squad logo, 512 px), so `claude plugin validate` reports no warnings. Images are marked binary in `.gitattributes`, so the plugin folder's LF rule never rewrites them. +- `.squad/templates/human-members.md` ships again. The template index listed it, but it had been missing since 2025. It describes adding a human to the roster and now includes the sign-off gate: a request to do gated work is not the sign-off. - `squad doctor` no longer fails the `squad_state` bridge check when the team folder has two spellings, such as macOS `/var` and `/private/var`. It compares the real paths. - When `squad_state` is unavailable and `.squad/config.json` sets a non-local `stateBackend`, the non-Copilot coordinators stop and say so instead of writing `.squad/decisions.md` directly, which that backend never reads. Seen in a live test with `two-layer`. - `squad init --client claude` tells you to run `claude --agent squad --mcp-config .mcp.json`, not Copilot. With several hosts it gives one line per host (#2453). diff --git a/docs/src/assets/images/squad-plugin-icon.png b/docs/src/assets/images/squad-plugin-icon.png new file mode 100644 index 000000000..ffc1b92ec Binary files /dev/null and b/docs/src/assets/images/squad-plugin-icon.png differ diff --git a/packages/squad-cli/templates/human-members.md b/packages/squad-cli/templates/human-members.md new file mode 100644 index 000000000..e71f38eaf --- /dev/null +++ b/packages/squad-cli/templates/human-members.md @@ -0,0 +1,83 @@ +# Human Members + +Reference for adding human team members to the Squad roster. + +## Triggers + +The coordinator enters the human-member flow when: + +- User says **"add {name} as {role}"** or **"add a human for {domain}"** +- User says **"make {name} a reviewer"** +- Routing detects a human member as the target for work + +## Comparison: AI Agent vs. Human Member + +| | AI Agent | Human Member | +|---|----------|-------------| +| Badge | Role-specific emoji | 👤 Human | +| Charter | ✅ | ❌ | +| History | ✅ | ❌ | +| Spawned as sub-agent | ✅ | ❌ | +| Can review work | ✅ | ✅ | +| Casting (fictional name) | ✅ | ❌ (real name) | + +## Adding a Human Member + +Follow these steps: + +1. **Use real name** — no casting. Badge: 👤 Human. +2. **Add to team.md roster:** + ```markdown + | {Name} | {Role} | — | 👤 Human | + ``` +3. **Add routing entries to routing.md:** + ```markdown + | {domain} | {Name} 👤 | {example tasks} | + ``` +4. **Do not create** `charter.md` or `history.md` for humans. +5. **Do not add** to casting registry. + +## Routing to a Human + +When work routes to a human member, the coordinator pauses. It never does that work itself or hands it to an AI member. + +**Sign-off gates.** When `team.md`, `routing.md` or a decision says a human must approve or sign off on a kind of work (a release, version bump, tag, publish, merge or deploy), a request to do that work is not the sign-off. Before any gated step the coordinator names the rule and the human, asks whether the user is that human or has their explicit sign-off, proceeds only on an explicit yes, and records it in the decisions inbox. In a non-interactive run it stops at the gate and reports what is waiting and for whom. + + +1. **Present the work to the user:** + ``` + "{Name} needs to review this. [Context about what needs review]" + ``` +2. **Non-dependent work continues immediately** — other agents proceed with unrelated tasks. +3. **Stale reminder** after >1 turn without response: + ``` + "📌 Still waiting on {Name} for {thing}." + ``` + +## Reviewing as a Human + +- Humans can serve as reviewers in the review process. +- **Rejection lockout applies normally** — original author is locked out when reviewer rejects. +- User relays the human's verdict to the coordinator ("approved" or "rejected with changes"). + +## Decision Framework: When to Add a Human Member + +| Scenario | Add to roster? | Why | +|----------|---------------|-----| +| Approves architecture decisions before implementation | ✅ Yes | Decision gate — team routes and waits | +| Reviews all docs PRs as standing reviewer | ✅ Yes | Recurring review gate | +| Occasionally reviews PRs when tagged | ❌ No | Use @mention on the PR | +| Files issues and contributes code | ❌ No | Normal GitHub collaboration | +| Makes final ship/no-ship call | ✅ Yes | Approval gate | + +**Litmus test:** If you want agents to *stop and wait* for someone's input before proceeding on a specific type of work, add them. If they review asynchronously via GitHub, don't bother. + +**Who is driving:** `git config user.name` tells the team who is probably at the keyboard. It is a hint, not a sign-off: for a gated step the coordinator still asks. Adding yourself to the roster is optional; it formalizes routing and tracking. + +## Removing a Human + +Same as removing any member: + +1. Move entry to alumni section in team.md. +2. Remove from active roster. +3. Update routing.md to remove all routing entries. diff --git a/packages/squad-sdk/src/client/adapters/claude-plugin.ts b/packages/squad-sdk/src/client/adapters/claude-plugin.ts index a2bfd79f7..6d17c3371 100644 --- a/packages/squad-sdk/src/client/adapters/claude-plugin.ts +++ b/packages/squad-sdk/src/client/adapters/claude-plugin.ts @@ -27,6 +27,15 @@ export const CLAUDE_PLUGIN_AGENT = `${CLAUDE_PLUGIN_NAME}:squad`; export const CLAUDE_PLUGIN_MCP_PREFIX = `mcp__plugin_${CLAUDE_PLUGIN_NAME}_squad_state__`; /** Where the generated plugin lives in the Squad repository; the marketplace points here. */ export const CLAUDE_PLUGIN_SOURCE_DIR = 'plugins/squad'; + +/** + * Binary files the plugin ships as they are, keyed by their path in the plugin, valued by their + * source in the repository. `scripts/build-claude-plugin.mjs` copies them; they are not generated. + * The icon (512 px square PNG) is the one `claude plugin validate` looks for at `.claude-plugin/icon.png`. + */ +export const CLAUDE_PLUGIN_STATIC_FILES: Readonly> = { + '.claude-plugin/icon.png': 'docs/src/assets/images/squad-plugin-icon.png', +}; /** Marketplace name: users install `squad@squad`. */ export const CLAUDE_MARKETPLACE_NAME = 'squad'; diff --git a/packages/squad-sdk/templates/human-members.md b/packages/squad-sdk/templates/human-members.md new file mode 100644 index 000000000..e71f38eaf --- /dev/null +++ b/packages/squad-sdk/templates/human-members.md @@ -0,0 +1,83 @@ +# Human Members + +Reference for adding human team members to the Squad roster. + +## Triggers + +The coordinator enters the human-member flow when: + +- User says **"add {name} as {role}"** or **"add a human for {domain}"** +- User says **"make {name} a reviewer"** +- Routing detects a human member as the target for work + +## Comparison: AI Agent vs. Human Member + +| | AI Agent | Human Member | +|---|----------|-------------| +| Badge | Role-specific emoji | 👤 Human | +| Charter | ✅ | ❌ | +| History | ✅ | ❌ | +| Spawned as sub-agent | ✅ | ❌ | +| Can review work | ✅ | ✅ | +| Casting (fictional name) | ✅ | ❌ (real name) | + +## Adding a Human Member + +Follow these steps: + +1. **Use real name** — no casting. Badge: 👤 Human. +2. **Add to team.md roster:** + ```markdown + | {Name} | {Role} | — | 👤 Human | + ``` +3. **Add routing entries to routing.md:** + ```markdown + | {domain} | {Name} 👤 | {example tasks} | + ``` +4. **Do not create** `charter.md` or `history.md` for humans. +5. **Do not add** to casting registry. + +## Routing to a Human + +When work routes to a human member, the coordinator pauses. It never does that work itself or hands it to an AI member. + +**Sign-off gates.** When `team.md`, `routing.md` or a decision says a human must approve or sign off on a kind of work (a release, version bump, tag, publish, merge or deploy), a request to do that work is not the sign-off. Before any gated step the coordinator names the rule and the human, asks whether the user is that human or has their explicit sign-off, proceeds only on an explicit yes, and records it in the decisions inbox. In a non-interactive run it stops at the gate and reports what is waiting and for whom. + + +1. **Present the work to the user:** + ``` + "{Name} needs to review this. [Context about what needs review]" + ``` +2. **Non-dependent work continues immediately** — other agents proceed with unrelated tasks. +3. **Stale reminder** after >1 turn without response: + ``` + "📌 Still waiting on {Name} for {thing}." + ``` + +## Reviewing as a Human + +- Humans can serve as reviewers in the review process. +- **Rejection lockout applies normally** — original author is locked out when reviewer rejects. +- User relays the human's verdict to the coordinator ("approved" or "rejected with changes"). + +## Decision Framework: When to Add a Human Member + +| Scenario | Add to roster? | Why | +|----------|---------------|-----| +| Approves architecture decisions before implementation | ✅ Yes | Decision gate — team routes and waits | +| Reviews all docs PRs as standing reviewer | ✅ Yes | Recurring review gate | +| Occasionally reviews PRs when tagged | ❌ No | Use @mention on the PR | +| Files issues and contributes code | ❌ No | Normal GitHub collaboration | +| Makes final ship/no-ship call | ✅ Yes | Approval gate | + +**Litmus test:** If you want agents to *stop and wait* for someone's input before proceeding on a specific type of work, add them. If they review asynchronously via GitHub, don't bother. + +**Who is driving:** `git config user.name` tells the team who is probably at the keyboard. It is a hint, not a sign-off: for a gated step the coordinator still asks. Adding yourself to the roster is optional; it formalizes routing and tracking. + +## Removing a Human + +Same as removing any member: + +1. Move entry to alumni section in team.md. +2. Remove from active roster. +3. Update routing.md to remove all routing entries. diff --git a/plugins/squad/.claude-plugin/icon.png b/plugins/squad/.claude-plugin/icon.png new file mode 100644 index 000000000..ffc1b92ec Binary files /dev/null and b/plugins/squad/.claude-plugin/icon.png differ diff --git a/scripts/build-claude-plugin.mjs b/scripts/build-claude-plugin.mjs index 0e54b375a..a1fc6ece1 100644 --- a/scripts/build-claude-plugin.mjs +++ b/scripts/build-claude-plugin.mjs @@ -25,7 +25,7 @@ if (!fs.existsSync(SDK)) { console.error(`build-claude-plugin: ${path.relative(ROOT, SDK)} is missing; run \`npm run build\` first.`); process.exit(1); } -const { buildClaudePlugin, buildClaudeMarketplace, CLAUDE_PLUGIN_SOURCE_DIR } = await import(pathToFileURL(SDK).href); +const { buildClaudePlugin, buildClaudeMarketplace, CLAUDE_PLUGIN_SOURCE_DIR, CLAUDE_PLUGIN_STATIC_FILES } = await import(pathToFileURL(SDK).href); const args = process.argv.slice(2); const check = args.includes('--check'); @@ -56,21 +56,27 @@ function listTree(dir, rel = '') { } const wanted = new Map(files.map((f) => [f.path, f])); +// Binary files shipped as they are (the icon): copied from the repository, compared byte for byte. +const statics = Object.entries(CLAUDE_PLUGIN_STATIC_FILES).map(([rel, src]) => ({ rel, bytes: fs.readFileSync(path.join(ROOT, src)) })); const stale = []; -for (const rel of listTree(pluginDir)) if (!wanted.has(rel)) stale.push(rel); +for (const rel of listTree(pluginDir)) if (!wanted.has(rel) && !(rel in CLAUDE_PLUGIN_STATIC_FILES)) stale.push(rel); const changed = []; for (const f of files) { const abs = path.join(pluginDir, f.path); const current = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : undefined; if (current !== f.content) changed.push(f.path); } +for (const { rel, bytes } of statics) { + const abs = path.join(pluginDir, rel); + if (!fs.existsSync(abs) || !fs.readFileSync(abs).equals(bytes)) changed.push(rel); +} const marketplaceStale = !fs.existsSync(marketplacePath) || fs.readFileSync(marketplacePath, 'utf8') !== marketplace; if (check) { const problems = [...changed.map((p) => `changed: ${p}`), ...stale.map((p) => `extra: ${p}`)]; if (marketplaceStale) problems.push('changed: .claude-plugin/marketplace.json'); // Marketplace installs clone the repository: a generated file git ignores never reaches users. - const ignored = spawnSync('git', ['check-ignore', '--no-index', '--', ...files.map((f) => path.join(CLAUDE_PLUGIN_SOURCE_DIR, f.path))], { + const ignored = spawnSync('git', ['check-ignore', '--no-index', '--', ...[...files.map((f) => f.path), ...statics.map((x) => x.rel)].map((p) => path.join(CLAUDE_PLUGIN_SOURCE_DIR, p))], { cwd: ROOT, encoding: 'utf8', }); @@ -90,8 +96,13 @@ for (const f of files) { fs.writeFileSync(abs, f.content, 'utf8'); if (f.executable) fs.chmodSync(abs, 0o755); } +for (const { rel, bytes } of statics) { + const abs = path.join(pluginDir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, bytes); +} fs.mkdirSync(path.dirname(marketplacePath), { recursive: true }); fs.writeFileSync(marketplacePath, marketplace, 'utf8'); console.log( - `build-claude-plugin: wrote ${files.length} files to ${CLAUDE_PLUGIN_SOURCE_DIR} (${changed.length} changed, ${stale.length} removed), version ${version}.`, + `build-claude-plugin: wrote ${files.length + statics.length} files to ${CLAUDE_PLUGIN_SOURCE_DIR} (${changed.length} changed, ${stale.length} removed), version ${version}.`, ); diff --git a/templates/human-members.md b/templates/human-members.md new file mode 100644 index 000000000..e71f38eaf --- /dev/null +++ b/templates/human-members.md @@ -0,0 +1,83 @@ +# Human Members + +Reference for adding human team members to the Squad roster. + +## Triggers + +The coordinator enters the human-member flow when: + +- User says **"add {name} as {role}"** or **"add a human for {domain}"** +- User says **"make {name} a reviewer"** +- Routing detects a human member as the target for work + +## Comparison: AI Agent vs. Human Member + +| | AI Agent | Human Member | +|---|----------|-------------| +| Badge | Role-specific emoji | 👤 Human | +| Charter | ✅ | ❌ | +| History | ✅ | ❌ | +| Spawned as sub-agent | ✅ | ❌ | +| Can review work | ✅ | ✅ | +| Casting (fictional name) | ✅ | ❌ (real name) | + +## Adding a Human Member + +Follow these steps: + +1. **Use real name** — no casting. Badge: 👤 Human. +2. **Add to team.md roster:** + ```markdown + | {Name} | {Role} | — | 👤 Human | + ``` +3. **Add routing entries to routing.md:** + ```markdown + | {domain} | {Name} 👤 | {example tasks} | + ``` +4. **Do not create** `charter.md` or `history.md` for humans. +5. **Do not add** to casting registry. + +## Routing to a Human + +When work routes to a human member, the coordinator pauses. It never does that work itself or hands it to an AI member. + +**Sign-off gates.** When `team.md`, `routing.md` or a decision says a human must approve or sign off on a kind of work (a release, version bump, tag, publish, merge or deploy), a request to do that work is not the sign-off. Before any gated step the coordinator names the rule and the human, asks whether the user is that human or has their explicit sign-off, proceeds only on an explicit yes, and records it in the decisions inbox. In a non-interactive run it stops at the gate and reports what is waiting and for whom. + + +1. **Present the work to the user:** + ``` + "{Name} needs to review this. [Context about what needs review]" + ``` +2. **Non-dependent work continues immediately** — other agents proceed with unrelated tasks. +3. **Stale reminder** after >1 turn without response: + ``` + "📌 Still waiting on {Name} for {thing}." + ``` + +## Reviewing as a Human + +- Humans can serve as reviewers in the review process. +- **Rejection lockout applies normally** — original author is locked out when reviewer rejects. +- User relays the human's verdict to the coordinator ("approved" or "rejected with changes"). + +## Decision Framework: When to Add a Human Member + +| Scenario | Add to roster? | Why | +|----------|---------------|-----| +| Approves architecture decisions before implementation | ✅ Yes | Decision gate — team routes and waits | +| Reviews all docs PRs as standing reviewer | ✅ Yes | Recurring review gate | +| Occasionally reviews PRs when tagged | ❌ No | Use @mention on the PR | +| Files issues and contributes code | ❌ No | Normal GitHub collaboration | +| Makes final ship/no-ship call | ✅ Yes | Approval gate | + +**Litmus test:** If you want agents to *stop and wait* for someone's input before proceeding on a specific type of work, add them. If they review asynchronously via GitHub, don't bother. + +**Who is driving:** `git config user.name` tells the team who is probably at the keyboard. It is a hint, not a sign-off: for a gated step the coordinator still asks. Adding yourself to the roster is optional; it formalizes routing and tracking. + +## Removing a Human + +Same as removing any member: + +1. Move entry to alumni section in team.md. +2. Remove from active roster. +3. Update routing.md to remove all routing entries. diff --git a/test/claude-plugin.test.ts b/test/claude-plugin.test.ts index e3ff4bc14..33665c09a 100644 --- a/test/claude-plugin.test.ts +++ b/test/claude-plugin.test.ts @@ -19,6 +19,7 @@ import { buildClaudeMarketplace, claudePluginCoordinatorInstructions, CLAUDE_PLUGIN_SOURCE_DIR, + CLAUDE_PLUGIN_STATIC_FILES, CLAUDE_PLUGIN_MCP_PREFIX, CLAUDE_HOOK_EVENTS, } from '../packages/squad-sdk/src/client/adapters/index.js'; @@ -52,8 +53,23 @@ const read = (rel: string) => fs.readFileSync(path.join(PLUGIN, rel), 'utf8').re describe('Claude plugin: committed tree matches the SDK', () => { const files = build(); - it('has exactly the generated files', () => { - expect(listTree(PLUGIN)).toEqual(files.map((f) => f.path).sort()); + it('has exactly the generated files, plus the static ones', () => { + expect(listTree(PLUGIN)).toEqual([...files.map((f) => f.path), ...Object.keys(CLAUDE_PLUGIN_STATIC_FILES)].sort()); + }); + + it('ships the icon the plugin directory wants: a square PNG of 512 to 2048 px under 2 MB, copied byte for byte', () => { + for (const [rel, src] of Object.entries(CLAUDE_PLUGIN_STATIC_FILES)) { + const bytes = fs.readFileSync(path.join(PLUGIN, rel)); + expect(bytes.equals(fs.readFileSync(path.join(ROOT, src))), `${rel}: run node scripts/build-claude-plugin.mjs`).toBe(true); + } + const icon = fs.readFileSync(path.join(PLUGIN, '.claude-plugin', 'icon.png')); + expect(icon.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + const width = icon.readUInt32BE(16); + const height = icon.readUInt32BE(20); + expect(width).toBe(height); + expect(width).toBeGreaterThanOrEqual(512); + expect(width).toBeLessThanOrEqual(2048); + expect(icon.length).toBeLessThan(2 * 1024 * 1024); }); it('every file is byte-identical to the generated content', () => { @@ -71,7 +87,7 @@ describe('Claude plugin: committed tree matches the SDK', () => { return [path.posix.relative(CLAUDE_PLUGIN_SOURCE_DIR, file!), meta!.split(' ')[0]] as const; }), ); - expect(files.map((f) => f.path).filter((p) => !modes.has(p))).toEqual([]); + expect([...files.map((f) => f.path), ...Object.keys(CLAUDE_PLUGIN_STATIC_FILES)].filter((p) => !modes.has(p))).toEqual([]); expect(modes.get('bin/squad')).toBe('100755'); });