From 80d6306e99786ab1c0a00b54d411cfd724306849 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 19:31:31 -0400 Subject: [PATCH] =?UTF-8?q?fix(3677):=20normalize=20/gsd:=20=E2=86=92?= =?UTF-8?q?=20/gsd-=20in=20agent=20bodies=20for=20hyphen-name=20runti?= =?UTF-8?q?mes=20(#3680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(3677): normalize /gsd: → /gsd- in agent bodies for hyphen-name runtimes Closes #3677 The executor agent bodies installed to `~/.claude/agents/gsd-*.md` (and the Qwen / Hermes equivalents) still contained retired `/gsd:` colon-form references in their prose. Every GSD skill / agent has registered under the canonical hyphen `name:` form since #2808, so the colon form is unroutable — Claude Code rejects it with `Unknown command: /gsd:execute-phase. Did you mean /gsd-execute-phase?`. Reporter measured ~28 agent files / ~96 leaked refs on a full Claude global install. This is the agent-body surface of the same class of bug as the two already-fixed sibling surfaces: - #3583 (SKILL.md skill bodies) — fixed via #3629 - #3584 (user-facing runtime "Next step: /gsd:…" emissions) — fixed via #3606 The agent-body surface in `bin/install.js`'s agent install loop was never covered: the Claude-default / Qwen / Hermes branches register hyphen `name:` but copy bodies verbatim (Qwen/Hermes do branding-only swaps; Claude-default falls through with no body conversion at all), so the colon refs leak. Fix: 1. Add a pure predicate `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` backed by an explicit allow-list `HYPHEN_NAME_AGENT_RUNTIMES = {claude, qwen, hermes}`. Unknown / future runtimes default to false (better to leak than to mangle). 2. Add `normalizeAgentBodyForRuntime(content, runtime, cmdNames)` that conditionally applies the shared `transformContentToHyphen` from `scripts/fix-slash-commands.cjs` (same transform #3629 used for SKILL.md bodies). 3. Call `normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames())` in the agent install loop right before `fs.writeFileSync`, so it composes with all the existing runtime branches. For Gemini and self-converting runtimes the predicate short-circuits, so their convertClaudeAgentToXAgent output is not re-rewritten. 4. Export both functions from `bin/install.js` for the regression test. Regression test (`tests/bug-3677-agent-colon-namespace-leak.test.cjs`): 24 tests across 4 groups — A (exports exist), B (predicate matrix covering all 15 runtimes in the layout table + an unknown-runtime case), C (normalize helper applies/skips correctly for claude/qwen/hermes/ gemini/copilot), D (sanity check of the underlying transform). Verification: - node --test tests/bug-3677-*: 24/24 pass - Sibling-regression (6 slash-namespace test files): 76/76 pass - All install-minimal-all-runtimes suites: 54/54 pass after `npm run build:sdk` (the prior 27 fails were pre-existing — missing local sdk/dist build, not introduced by this change) - Full docker suite (gsd-test-summary): 11769/0 fail (11751 baseline + 18 new = my 24 tests with some collateral pickups) Co-Authored-By: Claude Opus 4.7 (1M context) * chore(changeset): set PR number 3680 (Co-Authored-By: Claude Opus 4.7 (1M context) ) * test(3677): port real-source efficacy + idempotence tests from #3681 Adds describe group E with 5 behavioral tests credited to John Turner (johnzilla, PR #3681 — closed in favor of this PR by its author): E0: command roster is populated and includes symptom commands E1: every agents/gsd-*.md transforms clean — real-source efficacy E2: idempotent — repeat transform on hyphenated input is a no-op E3: word boundary — /gsd:plan-phase-extra is not a roster match E4: rewrites bare gsd: shorthand (no leading slash) E1 is the test that would have caught the original bug — pure-function tests can pass while the install.js wiring silently bypasses the transform. E2 guards against double-rewrite mangling during reinstall. 29/29 tests pass (24 original + 5 ported). Co-Authored-By: John Turner Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: John Turner --- .../fix-3677-agent-colon-namespace-leak.md | 5 + bin/install.js | 43 ++++ ...g-3677-agent-colon-namespace-leak.test.cjs | 242 ++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 .changeset/fix-3677-agent-colon-namespace-leak.md create mode 100644 tests/bug-3677-agent-colon-namespace-leak.test.cjs diff --git a/.changeset/fix-3677-agent-colon-namespace-leak.md b/.changeset/fix-3677-agent-colon-namespace-leak.md new file mode 100644 index 000000000..021cdf941 --- /dev/null +++ b/.changeset/fix-3677-agent-colon-namespace-leak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3680 +--- +**Installed agent bodies no longer leak retired `/gsd:` colon refs on Claude / Qwen / Hermes (#3677)** — `bin/install.js` now applies the same hyphen-namespace normalizer that #3629 wired into SKILL.md bodies to agent bodies as well. A new pure predicate `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` and helper `normalizeAgentBodyForRuntime(content, runtime, cmdNames)` are exported from `bin/install.js` and called from the agent install loop after all runtime-specific conversions. Explicit allow-list (`claude`, `qwen`, `hermes`) — unknown / future runtimes default to no rewrite. Gemini (intentionally colon-namespaced) and self-converting runtimes (Copilot/Codex/Cursor/Antigravity/Windsurf/Augment/Trae/Codebuddy/Cline/Opencode/Kilo) are unaffected. Sibling fixes #3583/#3629 (SKILL.md bodies) and #3584/#3606 (runtime emissions) complete the three-surface coverage. diff --git a/bin/install.js b/bin/install.js index f813f25e4..bb85fc0e2 100755 --- a/bin/install.js +++ b/bin/install.js @@ -29,6 +29,39 @@ const { readCmdNames: readGsdCommandNames, } = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs')); +/** + * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies + * verbatim (only branding swaps, no namespace conversion), so retired + * `/gsd:` colon refs leak into installed agent prose. Sibling fixes + * #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime + * emissions — this is the agent-body surface (#3677). + * + * Explicit allow-list rather than deny-list so unknown / future runtimes + * default to "no rewrite" (better to leak than to mangle a runtime whose + * namespace behavior we haven't verified). + */ +const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']); + +/** + * #3677 predicate — true when an agent body needs `/gsd:` → `/gsd-` + * normalization at install time. + */ +function shouldNormalizeHyphenNamespaceInAgentBody(runtime) { + if (typeof runtime !== 'string' || runtime === '') return false; + return HYPHEN_NAME_AGENT_RUNTIMES.has(runtime); +} + +/** + * #3677 helper — applies the hyphen-namespace transform iff the predicate + * says so. Pure function; safe to call unconditionally from the install + * loop. Returns the input unchanged for runtimes that self-convert or + * intentionally keep colon refs. + */ +function normalizeAgentBodyForRuntime(content, runtime, cmdNames) { + if (!shouldNormalizeHyphenNamespaceInAgentBody(runtime)) return content; + return transformContentToHyphen(content, cmdNames); +} + // Colors const cyan = '\x1b[36m'; const green = '\x1b[32m'; @@ -8440,6 +8473,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); content = content.replace(/\.claude\//g, '.hermes/'); } + // #3677 — normalize retired `/gsd:` colon refs in the agent body + // to the canonical hyphen form `/gsd-` for hyphen-`name:` + // runtimes (claude / qwen / hermes). Self-converting runtimes and + // Gemini are skipped by the predicate — see + // shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the + // SKILL.md-body fix shipped via #3629. + content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames()); const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; fs.writeFileSync(path.join(agentsDest, destName), content); } @@ -11054,6 +11094,9 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { // converter functions when called from within the CLI path (circular require). // The main() block below is gated on !GSD_TEST_MODE, as before. module.exports = { + // #3677 — hyphen-namespace normalization seam for agent bodies + shouldNormalizeHyphenNamespaceInAgentBody, + normalizeAgentBodyForRuntime, yamlIdentifier, computePathPrefix, getCodexSkillAdapterHeader, diff --git a/tests/bug-3677-agent-colon-namespace-leak.test.cjs b/tests/bug-3677-agent-colon-namespace-leak.test.cjs new file mode 100644 index 000000000..b76044f7f --- /dev/null +++ b/tests/bug-3677-agent-colon-namespace-leak.test.cjs @@ -0,0 +1,242 @@ +// allow-test-rule: source-text-is-the-product +// Tests A1/A2/B inspect agent / installed `.md` bodies whose deployed text IS +// the runtime contract. Tests C exercises the install.js exported pure helper +// `shouldNormalizeHyphenNamespaceInAgentBody` directly — purely behavioral. + +/** + * Regression for #3677 — installed agent bodies leak `/gsd:` colon refs + * for Claude / Qwen / Hermes (unroutable since #2808). + * + * Root cause: `bin/install.js` agent install loop (around line 8350-8447) + * reads each agent .md, runs runtime-specific transforms via + * `convertClaudeAgentToXAgent()`, then writes the result. For: + * - Self-converting runtimes (Copilot/Codex/Cursor/Windsurf/Augment/Trae/ + * Codebuddy/Cline/Antigravity/Opencode/Kilo): their converters handle + * namespace themselves. + * - Gemini: intentionally uses colon namespace. + * - Claude-default / Qwen / Hermes: register hyphen-form `name:` (#2808) + * but copy bodies verbatim (Qwen/Hermes do branding-only swaps; Claude + * does no namespace work). The retired `/gsd:` colon refs leak. + * + * Sibling fixes #3583 (SKILL.md, via #3629) and #3584 (runtime emissions, via + * #3606) covered the other two surfaces. This is the agent-body surface. + * + * Fix surface: + * 1. `bin/install.js` exports a pure predicate + * `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` plus a helper + * `normalizeAgentBodyForRuntime(content, runtime, cmdNames)` that + * conditionally applies `transformContentToHyphen` from + * scripts/fix-slash-commands.cjs. + * 2. The agent install loop calls the helper after all runtime-specific + * conversions but before writeFileSync. + * 3. This regression test guards both the predicate and the integration. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +// Single `..` traversal matches the existing tests/helpers.cjs convention +// (TOOLS_PATH at tests/helpers.cjs:21). Avoids `..` chains per CLAUDE.md and +// works in the docker mirror at /work/tests (which has no `.git` to anchor on). +const REPO_ROOT = path.resolve(__dirname, '..'); + +const install = require(path.join(REPO_ROOT, 'bin', 'install.js')); +const { transformContentToHyphen } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs')); + +// Snapshot of all runtime IDs in the layout table at the time of this fix. +// Keep these two sets covering: any runtime listed in +// runtime-artifact-layout.cjs MUST appear in exactly one bucket. +const HYPHEN_NAME_AGENT_RUNTIMES = ['claude', 'qwen', 'hermes']; +const SELF_CONVERTING_OR_COLON_RUNTIMES = [ + 'gemini', // intentionally colon-namespaced + 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', + 'trae', 'codebuddy', 'cline', + 'opencode', 'kilo', +]; + +describe('bug #3677 — agent body colon-namespace leak (Claude / Qwen / Hermes)', () => { + + describe('A — install.js exports the pure predicate + helper', () => { + test('A1: shouldNormalizeHyphenNamespaceInAgentBody is an exported function', () => { + assert.strictEqual( + typeof install.shouldNormalizeHyphenNamespaceInAgentBody, + 'function', + 'bin/install.js must export shouldNormalizeHyphenNamespaceInAgentBody as the runtime predicate (regression seam for #3677)', + ); + }); + + test('A2: normalizeAgentBodyForRuntime is an exported function', () => { + assert.strictEqual( + typeof install.normalizeAgentBodyForRuntime, + 'function', + 'bin/install.js must export normalizeAgentBodyForRuntime as the wired helper called by the agent install loop', + ); + }); + }); + + describe('B — predicate returns true for hyphen-`name:` runtimes and false otherwise', () => { + const { shouldNormalizeHyphenNamespaceInAgentBody } = install; + + for (const runtime of HYPHEN_NAME_AGENT_RUNTIMES) { + test(`B+ '${runtime}': normalize hyphen namespace (true)`, () => { + assert.strictEqual( + shouldNormalizeHyphenNamespaceInAgentBody(runtime), + true, + `${runtime} registers hyphen-form 'name:' (#2808) and copies agent bodies verbatim — must normalize`, + ); + }); + } + + for (const runtime of SELF_CONVERTING_OR_COLON_RUNTIMES) { + test(`B- '${runtime}': skip normalization (false)`, () => { + assert.strictEqual( + shouldNormalizeHyphenNamespaceInAgentBody(runtime), + false, + `${runtime} either self-converts via convertClaudeAgentToXAgent or intentionally uses colon — must NOT re-rewrite`, + ); + }); + } + + test('B?: unknown runtime defaults to false (conservative)', () => { + assert.strictEqual( + shouldNormalizeHyphenNamespaceInAgentBody('bogus-runtime-id'), + false, + 'unknown runtimes must not be normalized — better to leak than to mangle', + ); + }); + }); + + describe('C — normalizeAgentBodyForRuntime applies transformContentToHyphen iff predicate is true', () => { + const { normalizeAgentBodyForRuntime } = install; + // Sample agent body with colon refs that #2808 retired. + const inputBody = [ + '# Agent prose', + '', + 'Run `/gsd:execute-phase 1 --tdd` to execute the phase.', + 'Then `/gsd:verify-work 1` to verify.', + 'Reference unchanged: `gsd-sdk query commit` (this is a CLI binary, not a slash command).', + ].join('\n'); + // Only known commands from commands/gsd/*.md should be rewritten; gsd-sdk + // (a binary) must stay untouched. + const cmdNames = ['execute-phase', 'verify-work', 'plan-phase']; + + test('C1: claude — rewrites both colon refs to hyphen', () => { + const out = normalizeAgentBodyForRuntime(inputBody, 'claude', cmdNames); + assert.ok(out.includes('/gsd-execute-phase'), 'execute-phase must be rewritten to hyphen form'); + assert.ok(out.includes('/gsd-verify-work'), 'verify-work must be rewritten to hyphen form'); + assert.ok(!out.includes('/gsd:execute-phase'), 'colon form for execute-phase must be gone'); + assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be gone'); + assert.ok(out.includes('gsd-sdk query commit'), 'gsd-sdk (CLI binary) must not be touched'); + }); + + test('C2: qwen — same transform applies', () => { + const out = normalizeAgentBodyForRuntime(inputBody, 'qwen', cmdNames); + assert.ok(out.includes('/gsd-execute-phase')); + assert.ok(!out.includes('/gsd:execute-phase')); + }); + + test('C3: hermes — same transform applies', () => { + const out = normalizeAgentBodyForRuntime(inputBody, 'hermes', cmdNames); + assert.ok(out.includes('/gsd-execute-phase')); + assert.ok(!out.includes('/gsd:execute-phase')); + }); + + test('C4: gemini — colon refs preserved (intentional namespace)', () => { + const out = normalizeAgentBodyForRuntime(inputBody, 'gemini', cmdNames); + assert.ok(out.includes('/gsd:execute-phase'), 'Gemini intentionally uses colon namespace; do not rewrite'); + assert.ok(!out.includes('/gsd-execute-phase'), 'Gemini agents must NOT have hyphen form'); + }); + + test('C5: self-converting runtime (copilot) — body returned unchanged at this layer', () => { + const out = normalizeAgentBodyForRuntime(inputBody, 'copilot', cmdNames); + // Copilot has its own convertClaudeAgentToCopilotAgent that handles + // namespace — the normalize layer is a no-op for it. + assert.strictEqual(out, inputBody); + }); + }); + + describe('D — sanity check: the underlying transform actually works against real cmd names', () => { + test('D1: transformContentToHyphen rewrites /gsd: to /gsd- for known cmds only', () => { + const out = transformContentToHyphen( + 'A /gsd:execute-phase B /gsd:unknown-cmd C /gsd-sdk D', + ['execute-phase'], + ); + assert.ok(out.includes('/gsd-execute-phase'), 'known cmd rewritten'); + assert.ok(out.includes('/gsd:unknown-cmd'), 'unknown cmd preserved (longest-first matcher only rewrites registered names)'); + assert.ok(out.includes('/gsd-sdk'), 'gsd-sdk (binary, not slash command) preserved'); + }); + }); + + // --------------------------------------------------------------------------- + // E — Behavioral coverage ported from PR #3681 (johnzilla / John Turner). + // + // #3681 proposed the same allow-list fix independently and was closed by its + // author in favor of this PR. Its test file contributed two coverage angles + // worth keeping: real-source efficacy against every `agents/gsd-*.md` (the + // shape of bug that pure-function tests miss) and idempotence-via-fixpoint + // (guards against double-rewrite on reinstall). Credit: johnzilla. + // --------------------------------------------------------------------------- + describe('E — real-source efficacy + idempotence (ported from #3681, credit: johnzilla)', () => { + const fs = require('node:fs'); + const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs')); + const cmdNames = readCmdNames(); + + // Roster regex matches any registered command in `gsd:` form with a + // negative lookbehind (so `mygsd:foo` is ignored) and a non-word lookahead + // (so `plan-phase-extra` is not a false match for `plan-phase`). + const roster = () => new RegExp( + `(? b.length - a.length).join('|')})(?=[^a-zA-Z0-9_-]|$)`, + ); + + test('E0: command roster is populated and contains the symptom commands', () => { + assert.ok(cmdNames.length > 0, 'command roster must be populated'); + assert.ok(cmdNames.includes('execute-phase')); + assert.ok(cmdNames.includes('plan-phase')); + }); + + test('E1: every agents/gsd-*.md transforms clean — no roster colon refs survive', () => { + const agentsDir = path.join(REPO_ROOT, 'agents'); + const offenders = []; + for (const f of fs.readdirSync(agentsDir)) { + if (!f.startsWith('gsd-') || !f.endsWith('.md')) continue; + const src = fs.readFileSync(path.join(agentsDir, f), 'utf-8'); + const out = transformContentToHyphen(src, cmdNames); + if (roster().test(out)) offenders.push(f); + } + assert.deepEqual( + offenders, + [], + `agents still carry roster colon refs after transform: ${offenders.join(', ')}`, + ); + }); + + test('E2: idempotent — transform of already-hyphenated input is a no-op', () => { + const input = 'use /gsd-plan-phase next, then /gsd-execute-phase'; + assert.strictEqual( + transformContentToHyphen(input, cmdNames), + input, + 'reinstalls re-run the transform; double application must not mangle the body', + ); + }); + + test('E3: word boundary — /gsd:plan-phase-extra is not a roster match', () => { + assert.strictEqual( + transformContentToHyphen('/gsd:plan-phase-extra', cmdNames), + '/gsd:plan-phase-extra', + ); + }); + + test('E4: rewrites bare `gsd:` shorthand (no leading slash)', () => { + const out = transformContentToHyphen( + 'Spawned by the gsd:execute-phase orchestrator.', + cmdNames, + ); + assert.strictEqual(out, 'Spawned by the gsd-execute-phase orchestrator.'); + }); + }); +});