From 09ab16f9e537254d63974ed38f08b56e3ce7af34 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 00:13:59 -0400 Subject: [PATCH] =?UTF-8?q?fix(3683):=20normalize=20/gsd:=20=E2=86=92?= =?UTF-8?q?=20/gsd-=20in=20command,=20workflow,=20and=20reference=20b?= =?UTF-8?q?odies=20(#3685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(3683): normalize /gsd: → /gsd- in command, workflow, and reference bodies Extends #3677's agent-body normalizer to all body text staged through copyWithPathReplacement (commands, workflows, references). The initial isCommand guard was structurally redundant — normalizeAgentBodyForRuntime already self-gates on shouldNormalizeHyphenNamespaceInAgentBody(runtime), so dropping it covers all hyphen-name runtimes (Claude / Qwen / Hermes) without affecting colon-canonical runtimes (Gemini). Addresses the user-visible symptom in #3683: workflows like get-shit-done/workflows/discuss-phase.md (7 colon refs) leaked /gsd: markers to the model context, which the model echoed at the end of /gsd-discuss-phase runs. Source-prose drift caught by the new cross-reference invariant test: - commands/gsd/plan-phase.md: removed a slash-form mention of the deleted /gsd-research-phase command (#3042) - commands/gsd/profile-user.md: replaced a slash-form artifact reference with a backticked bare name (the referenced item is a skill config, not a user-callable slash command) Tests: - tests/bug-3683-command-colon-namespace-leak.test.cjs — runtime-form regression for commands/gsd/*.md staging - tests/bug-3683-command-cross-reference-invariant.test.cjs — locks cross-reference coherence: every /gsd-X / /gsd:X reference in a command body must resolve to commands/gsd/X.md (so a future rename forces every cross-reference to update) - tests/bug-3683-workflow-colon-namespace-leak.test.cjs — runtime-form regression for workflows + references; negative test for gemini asserting the colon form is preserved Fixes #3683 Co-Authored-By: Claude Opus 4.7 (1M context) * docs(changeset): remove undefined cycle reference --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/sturdy-otters-purr.md | 5 + bin/install.js | 7 + commands/gsd/plan-phase.md | 2 +- commands/gsd/profile-user.md | 2 +- ...3683-command-colon-namespace-leak.test.cjs | 228 +++++++++++++++ ...command-cross-reference-invariant.test.cjs | 67 +++++ ...683-workflow-colon-namespace-leak.test.cjs | 271 ++++++++++++++++++ 7 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 .changeset/sturdy-otters-purr.md create mode 100644 tests/bug-3683-command-colon-namespace-leak.test.cjs create mode 100644 tests/bug-3683-command-cross-reference-invariant.test.cjs create mode 100644 tests/bug-3683-workflow-colon-namespace-leak.test.cjs diff --git a/.changeset/sturdy-otters-purr.md b/.changeset/sturdy-otters-purr.md new file mode 100644 index 000000000..956c8c32e --- /dev/null +++ b/.changeset/sturdy-otters-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3685 +--- +**Installed command, workflow, and reference bodies no longer leak retired `/gsd:` colon refs on Claude / Qwen / Hermes (#3683)** — extends #3677's agent-body normalizer to all body text staged through `copyWithPathReplacement` (commands, workflows, references). The redundant `if (isCommand)` guard was removed — `normalizeAgentBodyForRuntime` already self-gates on `shouldNormalizeHyphenNamespaceInAgentBody(runtime)`. Adds a cross-reference invariant test that asserts every `/gsd-X` / `/gsd:X` reference in a command body resolves to an existing `commands/gsd/X.md`. Gemini-canonical (colon) runtimes are unaffected. diff --git a/bin/install.js b/bin/install.js index bb85fc0e2..ad2661724 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6301,6 +6301,13 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand } content = processAttribution(content, getCommitAttribution(runtime)); + // #3683 — normalize /gsd: → /gsd- in any body passing through + // copyWithPathReplacement for runtimes that register commands under the + // hyphen form; normalizeAgentBodyForRuntime self-gates on + // shouldNormalizeHyphenNamespaceInAgentBody(runtime) and is a no-op for + // colon-canonical runtimes (Gemini). + content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames()); + // Convert frontmatter for opencode compatibility if (isOpencode || isKilo) { content = isKilo diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index 9da6c78f6..778771ccc 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -19,7 +19,7 @@ Create executable phase prompts (PLAN.md files) for a roadmap phase with integra **Default flow:** Research (if needed) → Plan → Verify → Done -**Research-only mode (`--research-phase `):** Spawn `gsd-phase-researcher` for phase `N`, write `RESEARCH.md`, then exit before the planner runs. Useful for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops where iterating on research alone is dramatically cheaper than re-spawning the planner. Replaces the deleted `/gsd-research-phase` command (#3042). +**Research-only mode (`--research-phase `):** Spawn `gsd-phase-researcher` for phase `N`, write `RESEARCH.md`, then exit before the planner runs. Useful for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops where iterating on research alone is dramatically cheaper than re-spawning the planner. Replaces the deleted research-phase command (#3042). **Research-only modifiers:** - **No flag** — when `RESEARCH.md` already exists, prompt the user to choose `update / view / skip`. diff --git a/commands/gsd/profile-user.md b/commands/gsd/profile-user.md index 4b14beaef..c5bd0e479 100644 --- a/commands/gsd/profile-user.md +++ b/commands/gsd/profile-user.md @@ -13,7 +13,7 @@ allowed-tools: --- -Generate a developer behavioral profile from session analysis (or questionnaire) and produce artifacts (USER-PROFILE.md, /gsd-dev-preferences, CLAUDE.md section) that personalize Claude's responses. +Generate a developer behavioral profile from session analysis (or questionnaire) and produce artifacts (USER-PROFILE.md, `gsd-dev-preferences` skill config, CLAUDE.md section) that personalize Claude's responses. Routes to the profile-user workflow which orchestrates the full flow: consent gate, session analysis or questionnaire fallback, profile generation, result display, and artifact selection. diff --git a/tests/bug-3683-command-colon-namespace-leak.test.cjs b/tests/bug-3683-command-colon-namespace-leak.test.cjs new file mode 100644 index 000000000..06db42156 --- /dev/null +++ b/tests/bug-3683-command-colon-namespace-leak.test.cjs @@ -0,0 +1,228 @@ +// allow-test-rule: source-text-is-the-product +// Command `.md` files — their staged text IS the runtime contract loaded by +// Claude Code. Asserting that staged bodies lack `/gsd:` colon refs is +// a behavioral test of the install transform, not source-grep theater. + +/** + * Regression for #3683 — installed command bodies leak `/gsd:` colon refs + * for Claude Code local installs. + * + * Root cause: `bin/install.js` command install path (`copyWithPathReplacement`, + * around line 8296 in the `else` branch) copies each command `.md` body without + * applying the hyphen-namespace normalizer that the agent install loop gained in + * PR #3677. Static prose in `commands/gsd/*.md` (e.g. plan-phase.md referencing + * `/gsd:execute-phase`) therefore reaches the model verbatim, causing the model + * to echo the retired colon form at workflow boundaries. + * + * Fix surface: + * Call `normalizeAgentBodyForRuntime` (or an equivalent helper) in the command + * staging path after all other rewrites but before writeFileSync, mirroring + * the agent install loop fix from #3677. + * + * This test guards the behavioral integration: run a real local claude install + * into a temp dir, then assert that no staged command body contains a + * `/gsd:` colon ref. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); + +const install = require(INSTALL_PATH); +const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs')); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Run `node install.js --claude --local --no-sdk` in tmpDir. + * GSD_TEST_MODE must be cleared so the install() main block executes. + */ +function runClaudeLocalInstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +/** + * Build the roster regex that matches `/gsd:` or `gsd:` + * (with appropriate word boundaries). Mirrors the pattern used in bug-3677. + */ +function buildRosterRegex(cmdNames) { + const sorted = [...cmdNames].sort((a, b) => b.length - a.length); + return new RegExp( + `(? { + + describe('A — install.js exports the normalizer seam', () => { + test('A1: normalizeAgentBodyForRuntime is exported (reused for command bodies)', () => { + assert.strictEqual( + typeof install.normalizeAgentBodyForRuntime, + 'function', + 'bin/install.js must export normalizeAgentBodyForRuntime — the seam used for both agent and command body normalization', + ); + }); + + test('A2: shouldNormalizeHyphenNamespaceInAgentBody is exported and true for claude', () => { + assert.strictEqual( + typeof install.shouldNormalizeHyphenNamespaceInAgentBody, + 'function', + ); + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('claude'), + true, + 'claude must normalize hyphen namespace — it is in the allow-list from #2808', + ); + }); + }); + + // --------------------------------------------------------------------------- + // B — pure-function coverage: normalizer rewrites command body colon refs + // --------------------------------------------------------------------------- + describe('B — normalizeAgentBodyForRuntime rewrites colon refs in command-body prose', () => { + const { normalizeAgentBodyForRuntime } = install; + const cmdNames = readCmdNames(); + + test('B0: command roster is populated and includes symptom commands', () => { + assert.ok(cmdNames.length > 0, 'readCmdNames() must return a non-empty list'); + assert.ok(cmdNames.includes('execute-phase'), 'roster must include execute-phase'); + assert.ok(cmdNames.includes('plan-phase'), 'roster must include plan-phase'); + }); + + test('B1: claude — rewrites /gsd: colon refs in command-body prose to hyphen form', () => { + const input = [ + '## After planning', + '', + 'Run `/gsd:execute-phase 1 --tdd` to begin execution.', + 'Then use `/gsd:verify-work 1` when done.', + ].join('\n'); + const out = normalizeAgentBodyForRuntime(input, '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 absent'); + assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be absent'); + }); + + test('B2: gemini — colon refs preserved (Gemini intentionally uses colon namespace)', () => { + const input = 'Run `/gsd:execute-phase 1` to begin.'; + const out = normalizeAgentBodyForRuntime(input, 'gemini', cmdNames); + assert.ok(out.includes('/gsd:execute-phase'), 'Gemini must keep colon form'); + assert.ok(!out.includes('/gsd-execute-phase'), 'Gemini must not have hyphen form injected'); + }); + }); + + // --------------------------------------------------------------------------- + // E — Integration: real local claude install produces clean command bodies + // --------------------------------------------------------------------------- + describe('E — integration: staged commands/gsd/*.md files contain no colon-namespace refs', () => { + let tmpDir; + const cmdNames = readCmdNames(); + const rosterRegex = buildRosterRegex(cmdNames); + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-')); + runClaudeLocalInstall(tmpDir); + }); + + after(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + test('E0: staged commands/gsd/ directory exists after install', () => { + const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + assert.ok( + fs.existsSync(commandsDir), + `commands/gsd/ must be created by local claude install at ${commandsDir}`, + ); + }); + + test('E1: no staged command body contains /gsd: colon refs', () => { + const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + assert.ok(fs.existsSync(commandsDir), 'commands/gsd/ must exist for this check to be meaningful'); + + const offenders = []; + + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name.endsWith('.md')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + if (rosterRegex.test(content)) { + const rel = path.relative(tmpDir, fullPath); + offenders.push(rel); + } + } + } + }; + + walk(commandsDir); + + assert.deepEqual( + offenders, + [], + `Staged command bodies still contain roster colon refs (e.g. /gsd:execute-phase). ` + + `Install must normalize these to /gsd- for claude runtime. Offenders: ${offenders.join(', ')}`, + ); + }); + + test('E2: idempotent — re-running install does not double-mangle already-hyphenated refs', () => { + // Run install a second time; if the normalizer double-applies it would + // produce garbled output like /gsd--execute-phase. Verify the directory + // still passes the same cleanliness check after a second install. + runClaudeLocalInstall(tmpDir); + + const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + const doubleRewriteRegex = /\/gsd--[a-z]/; + const garbled = []; + + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name.endsWith('.md')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + if (doubleRewriteRegex.test(content)) { + garbled.push(path.relative(tmpDir, fullPath)); + } + } + } + }; + + walk(commandsDir); + + assert.deepEqual( + garbled, + [], + `Re-install produced double-hyphen artifacts (/gsd--cmd) — normalizer is not idempotent. Garbled files: ${garbled.join(', ')}`, + ); + }); + }); +}); diff --git a/tests/bug-3683-command-cross-reference-invariant.test.cjs b/tests/bug-3683-command-cross-reference-invariant.test.cjs new file mode 100644 index 000000000..21db74ad6 --- /dev/null +++ b/tests/bug-3683-command-cross-reference-invariant.test.cjs @@ -0,0 +1,67 @@ +// allow-test-rule: source-text-is-the-product +// commands/gsd/*.md bodies are the deployed contract — cross-references between +// them must stay coherent. This test inspects .md source to enforce the invariant. + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const COMMANDS_DIR = path.resolve(__dirname, '..', 'commands', 'gsd'); + +function readKnownTargets() { + const commandNames = fs.readdirSync(COMMANDS_DIR) + .filter(f => f.endsWith('.md')) + .map(f => f.slice(0, -3)); + return { commandNames, knownTargets: new Set(commandNames) }; +} + +function stripFrontmatter(src) { + return src.replace(/^---\r?\n[\s\S]*?\n---\r?\n/, ''); +} + +// Word-boundary lookbehind matching fix-slash-commands.cjs buildColonPattern / buildPattern +// Excludes path-y characters (~, ., /) so `~/gsd-workspaces`, `./gsd-foo`, `path/gsd-bar` don't match. +const REF_PATTERN = /(? { + test('all /gsd: and /gsd- body refs resolve to known command base-names', () => { + const { commandNames, knownTargets: knownSet } = readKnownTargets(); + const mdFiles = commandNames.sort().map(n => path.join(COMMANDS_DIR, `${n}.md`)); + + const failures = []; + + for (const filePath of mdFiles) { + const src = fs.readFileSync(filePath, 'utf-8'); + const body = stripFrontmatter(src); + const lines = body.split('\n'); + const relFile = path.relative(path.resolve(__dirname, '..'), filePath); + + lines.forEach((line, idx) => { + REF_PATTERN.lastIndex = 0; + let m; + while ((m = REF_PATTERN.exec(line)) !== null) { + const ref = m[1]; + if (!knownSet.has(ref)) { + const sep = m[0].includes(':') ? ':' : '-'; + failures.push({ + file: relFile, + line: idx + 1, + ref: `/gsd${sep}${ref}`, + excerpt: line.trim(), + }); + } + } + }); + } + + if (failures.length > 0) { + const msg = failures + .map(f => ` ${f.file}:${f.line} — dangling ref "${f.ref}" — ${f.excerpt}`) + .join('\n'); + assert.fail(`Dangling command cross-references found:\n${msg}`); + } + }); +}); diff --git a/tests/bug-3683-workflow-colon-namespace-leak.test.cjs b/tests/bug-3683-workflow-colon-namespace-leak.test.cjs new file mode 100644 index 000000000..50203d1bc --- /dev/null +++ b/tests/bug-3683-workflow-colon-namespace-leak.test.cjs @@ -0,0 +1,271 @@ +// allow-test-rule: source-text-is-the-product +// Workflow and reference `.md` files are deployed verbatim as part of the +// get-shit-done skill payload — their staged text IS the runtime contract +// loaded by Claude Code. Asserting that staged bodies lack `/gsd:` +// colon refs is a behavioral test of the install transform, not +// source-grep theater. + +/** + * Regression for #3683 — installed workflow/reference bodies leak `/gsd:` + * colon refs for Claude Code local installs. + * + * Root cause: `copyWithPathReplacement` in `bin/install.js` guarded the + * `normalizeAgentBodyForRuntime` call behind `if (isCommand)`, so the + * `get-shit-done/` directory (workflows, references — all `isCommand=false`) + * was copied without applying the hyphen-namespace normalizer. Static prose + * in `get-shit-done/workflows/*.md` and `get-shit-done/references/*.md` + * (e.g. discuss-phase.md referencing `/gsd:plan-phase`) therefore reached + * the model verbatim, causing it to echo the retired colon form. + * + * Fix surface: + * Remove the `if (isCommand)` guard so `normalizeAgentBodyForRuntime` is + * called unconditionally in `copyWithPathReplacement`. The function + * self-gates on `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` and + * is a no-op for colon-canonical runtimes (Gemini, Codex, etc.). + * + * User repro path: `/gsd-discuss-phase` output ends with `/gsd:nextcommand` + * because discuss-phase.md (7 colon refs) is not normalized at install time. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); + +const install = require(INSTALL_PATH); +const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs')); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Run `node install.js --claude --local --no-sdk` in tmpDir. + * GSD_TEST_MODE must be cleared so the install() main block executes. + */ +function runClaudeLocalInstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +/** + * Run `node install.js --gemini --local --no-sdk` in tmpDir. + * GSD_TEST_MODE must be cleared so the install() main block executes. + */ +function runGeminiLocalInstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + execFileSync(process.execPath, [INSTALL_PATH, '--gemini', '--local', '--no-sdk'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +/** + * Build the roster regex that matches `gsd:` references. + * Mirrors the pattern used by the Cycle 1 command test. + */ +function buildRosterRegex(cmdNames) { + const sorted = [...cmdNames].sort((a, b) => b.length - a.length); + return new RegExp( + `(? { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const fullPath = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name.endsWith('.md')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + if (regex.test(content)) { + offenders.push(fullPath); + } + } + } + }; + walk(dir); + return offenders; +} + +// --------------------------------------------------------------------------- +// Suite — integration: staged get-shit-done/workflows/ and references/ must +// have no colon-namespace refs for claude, and must preserve them for gemini. +// --------------------------------------------------------------------------- +describe('bug #3683 — workflow/reference colon-namespace leak (Claude local install)', () => { + + // ------------------------------------------------------------------------- + // W — real local claude install: workflow + reference bodies are clean + // ------------------------------------------------------------------------- + describe('W — integration: staged workflows and references contain no colon-namespace refs', () => { + let tmpDir; + const cmdNames = readCmdNames(); + const rosterRegex = buildRosterRegex(cmdNames); + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-wf-')); + runClaudeLocalInstall(tmpDir); + }); + + after(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + test('W0: staged get-shit-done/workflows/ directory exists after install', () => { + const workflowsDir = path.join(tmpDir, '.claude', 'get-shit-done', 'workflows'); + assert.ok( + fs.existsSync(workflowsDir), + `get-shit-done/workflows/ must be created by local claude install at ${workflowsDir}`, + ); + }); + + test('W1: staged get-shit-done/references/ directory exists after install', () => { + const refsDir = path.join(tmpDir, '.claude', 'get-shit-done', 'references'); + assert.ok( + fs.existsSync(refsDir), + `get-shit-done/references/ must be created by local claude install at ${refsDir}`, + ); + }); + + test('W2: focused repro — staged discuss-phase.md has zero /gsd: colon refs', () => { + // User-reported repro: /gsd-discuss-phase output ends with /gsd:nextcommand + // because discuss-phase.md ships 7 colon refs that were not normalized. + const stagedFile = path.join( + tmpDir, '.claude', 'get-shit-done', 'workflows', 'discuss-phase.md', + ); + assert.ok( + fs.existsSync(stagedFile), + `discuss-phase.md must exist in staged get-shit-done/workflows/`, + ); + const content = fs.readFileSync(stagedFile, 'utf-8'); + const colonMatches = content.match(/gsd:[a-z][a-z0-9-]*/g) || []; + // Filter to known-command refs only + const knownColonRefs = colonMatches.filter(m => { + const cmd = m.slice(4); // strip 'gsd:' + return cmdNames.includes(cmd); + }); + assert.deepEqual( + knownColonRefs, + [], + `discuss-phase.md still contains colon-namespace refs that install must normalize: ${knownColonRefs.join(', ')}`, + ); + }); + + test('W3: no staged workflow body contains /gsd: colon refs', () => { + const workflowsDir = path.join(tmpDir, '.claude', 'get-shit-done', 'workflows'); + assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for this check to be meaningful'); + + const offenders = collectOffenders(workflowsDir, rosterRegex); + const relOffenders = offenders.map(f => path.relative(tmpDir, f)); + + assert.deepEqual( + relOffenders, + [], + `Staged workflow bodies still contain roster colon refs (e.g. /gsd:plan-phase). ` + + `Install must normalize these to /gsd- for claude runtime. Offenders: ${relOffenders.join(', ')}`, + ); + }); + + test('W4: no staged reference body contains /gsd: colon refs', () => { + const refsDir = path.join(tmpDir, '.claude', 'get-shit-done', 'references'); + assert.ok(fs.existsSync(refsDir), 'references/ must exist for this check to be meaningful'); + + const offenders = collectOffenders(refsDir, rosterRegex); + const relOffenders = offenders.map(f => path.relative(tmpDir, f)); + + assert.deepEqual( + relOffenders, + [], + `Staged reference bodies still contain roster colon refs. ` + + `Install must normalize these to /gsd- for claude runtime. Offenders: ${relOffenders.join(', ')}`, + ); + }); + }); + + // ------------------------------------------------------------------------- + // G — negative: gemini install must PRESERVE colon form (no-op normalizer) + // ------------------------------------------------------------------------- + describe('G — negative: staged gemini workflows preserve colon-namespace refs', () => { + let tmpDir; + const cmdNames = readCmdNames(); + const rosterRegex = buildRosterRegex(cmdNames); + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-gem-')); + runGeminiLocalInstall(tmpDir); + }); + + after(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + test('G0: staged gemini get-shit-done/workflows/ directory exists after install', () => { + const workflowsDir = path.join(tmpDir, '.gemini', 'get-shit-done', 'workflows'); + assert.ok( + fs.existsSync(workflowsDir), + `gemini get-shit-done/workflows/ must be created at ${workflowsDir}`, + ); + }); + + test('G1: gemini discuss-phase.md preserves colon form (normalizer is a no-op for gemini)', () => { + // Gemini registers /gsd: as its canonical form — normalization + // must NOT fire for this runtime. Verify colon refs survive unchanged. + const stagedFile = path.join( + tmpDir, '.gemini', 'get-shit-done', 'workflows', 'discuss-phase.md', + ); + assert.ok( + fs.existsSync(stagedFile), + `gemini discuss-phase.md must exist in staged get-shit-done/workflows/`, + ); + const content = fs.readFileSync(stagedFile, 'utf-8'); + // The source has 7 colon refs; at least one must be present in gemini output. + const colonMatches = content.match(/gsd:[a-z][a-z0-9-]*/g) || []; + const knownColonRefs = colonMatches.filter(m => cmdNames.includes(m.slice(4))); + assert.ok( + knownColonRefs.length > 0, + `gemini staged discuss-phase.md must preserve /gsd: colon refs — ` + + `they are Gemini's canonical command namespace and must not be rewritten to hyphen form`, + ); + }); + + test('G2: gemini workflows are not over-normalized (no /gsd-- double-hyphen artifacts)', () => { + const workflowsDir = path.join(tmpDir, '.gemini', 'get-shit-done', 'workflows'); + if (!fs.existsSync(workflowsDir)) return; // guard — G0 already asserts existence + const doubleHyphenRegex = /\/gsd--[a-z]/; + const garbled = collectOffenders(workflowsDir, doubleHyphenRegex); + const relGarbled = garbled.map(f => path.relative(tmpDir, f)); + assert.deepEqual( + relGarbled, + [], + `Gemini staged workflows contain /gsd-- double-hyphen artifacts — normalizer ran when it should not have. Garbled: ${relGarbled.join(', ')}`, + ); + }); + }); +});