* 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>
243 lines
11 KiB
JavaScript
243 lines
11 KiB
JavaScript
// 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.');
|
|
});
|
|
});
|
|
});
|