fix(3677): normalize /gsd:<cmd> → /gsd-<cmd> in agent bodies for hyphen-name runtimes (#3680)
* fix(3677): normalize /gsd:<cmd> → /gsd-<cmd> 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:<cmd>` 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) <noreply@anthropic.com> * chore(changeset): set PR number 3680 (Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>) * 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:<cmd> 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 <johnzilla@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: John Turner <johnzilla@users.noreply.github.com>
This commit is contained in:
5
.changeset/fix-3677-agent-colon-namespace-leak.md
Normal file
5
.changeset/fix-3677-agent-colon-namespace-leak.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3680
|
||||
---
|
||||
**Installed agent bodies no longer leak retired `/gsd:<cmd>` 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.
|
||||
@@ -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:<cmd>` 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:<cmd>` → `/gsd-<cmd>`
|
||||
* 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:<cmd>` colon refs in the agent body
|
||||
// to the canonical hyphen form `/gsd-<cmd>` 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,
|
||||
|
||||
242
tests/bug-3677-agent-colon-namespace-leak.test.cjs
Normal file
242
tests/bug-3677-agent-colon-namespace-leak.test.cjs
Normal file
@@ -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:<cmd>` 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:<cmd>` 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:<cmd> to /gsd-<cmd> 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:<cmd>` 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(
|
||||
`(?<![a-zA-Z0-9_-])gsd:(${[...cmdNames].sort((a, b) => 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:<cmd>` 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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user