diff --git a/bin/install.js b/bin/install.js index a3ab67b67..5f48fc6e9 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1123,21 +1123,21 @@ function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false /** * Map a skill directory name (gsd-) to the frontmatter `name:` used - * by Claude Code as the skill identity. Workflows emit `Skill(skill="gsd:")` - * (colon form) and Claude Code resolves skills by frontmatter `name:`, not - * directory name — so emit colon form here. Directory stays hyphenated for - * Windows path safety. See #2643. + * by Claude Code as the skill identity. Emits the hyphen form (gsd-) + * so Claude Code autocomplete shows the canonical invocation form, not the + * deprecated colon form. See #2808. + * + * Historical note: this previously returned `gsd:` (colon) because + * workflows called Skill(skill="gsd:"). Those calls have been updated + * to use hyphen form (#2808) so the colon rewrite is no longer needed. * * Codex must NOT use this helper: its adapter invokes skills as `$gsd-` - * (shell-var syntax) and a colon would terminate the variable name. Codex - * keeps the hyphen form via `yamlQuote(skillName)` directly. + * (shell-var syntax) — hyphen form is already correct there. */ function skillFrontmatterName(skillDirName) { if (typeof skillDirName !== 'string') return skillDirName; - // Idempotent on already-colon form. - if (skillDirName.includes(':')) return skillDirName; - // Only rewrite the first hyphen after the `gsd` prefix. - return skillDirName.replace(/^gsd-/, 'gsd:'); + // Return the hyphen form as-is (gsd-) — canonical since #2808. + return skillDirName; } /** diff --git a/get-shit-done/workflows/autonomous.md b/get-shit-done/workflows/autonomous.md index bac30e727..4b1fc953d 100644 --- a/get-shit-done/workflows/autonomous.md +++ b/get-shit-done/workflows/autonomous.md @@ -262,7 +262,7 @@ The discuss step in `--auto` mode MUST NOT loop. If CONTEXT.md already exists af **If `INTERACTIVE` is set:** Run the standard discuss-phase skill inline (asks interactive questions, waits for user answers). This preserves user input on all design decisions while keeping plan+execute out of the main context: ``` -Skill(skill="gsd:discuss-phase", args="${PHASE_NUM}") +Skill(skill="gsd-discuss-phase", args="${PHASE_NUM}") ``` **If `INTERACTIVE` is NOT set:** Execute the smart_discuss step for this phase (batch table proposals, auto-optimized). @@ -367,12 +367,12 @@ CODE_REVIEW_ENABLED=$(gsd-sdk query config-get workflow.code_review 2>/dev/null If `"false"`: display "Code review skipped (workflow.code_review=false)" and proceed to 3d. ``` -Skill(skill="gsd:code-review", args="${PHASE_NUM}") +Skill(skill="gsd-code-review", args="${PHASE_NUM}") ``` Parse status from REVIEW.md frontmatter. If "clean" or "skipped": proceed to 3d. If findings found: auto-invoke: -``` -Skill(skill="gsd:code-review-fix", args="${PHASE_NUM} --auto") +```text +Skill(skill="gsd-code-review-fix", args="${PHASE_NUM} --auto") ``` **Error handling:** If either Skill fails, catch the error, display as non-blocking, and proceed to 3d. diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 87fef1b8e..2e1c8c53a 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -1095,7 +1095,7 @@ If `CODE_REVIEW_ENABLED` is `"false"`: display "Code review skipped (workflow.co **Invoke review:** ``` -Skill(skill="gsd:code-review", args="${PHASE_NUMBER}") +Skill(skill="gsd-code-review", args="${PHASE_NUMBER}") ``` **Check results using deterministic path (not glob):** diff --git a/tests/bug-2643-skill-frontmatter-name.test.cjs b/tests/bug-2643-skill-frontmatter-name.test.cjs index a5983fb85..662229c76 100644 --- a/tests/bug-2643-skill-frontmatter-name.test.cjs +++ b/tests/bug-2643-skill-frontmatter-name.test.cjs @@ -3,12 +3,15 @@ process.env.GSD_TEST_MODE = '1'; /** - * Bug #2643: workflows emit Skill(skill="gsd:") but flat-skills install - * registers `gsd-` as the frontmatter `name:`. Claude Code uses the - * frontmatter name (not dir name) as the skill identity — so the emitted - * `name:` must match the colon form used by workflow Skill() calls. + * Bug #2643 / #2808: skill frontmatter name parity. * - * The directory name stays hyphenated for Windows path safety. + * Original (#2643): workflows emitted Skill(skill="gsd:") and the + * installer registered colon form in SKILL.md name: to match. + * + * Updated (#2808): workflows now use Skill(skill="gsd-") (hyphen), + * and the installer emits name: gsd- (hyphen). Claude Code autocomplete + * now shows the canonical hyphen form instead of the deprecated colon form. + * The directory name (gsd-) is unchanged. */ const { test, describe } = require('node:test'); @@ -37,7 +40,15 @@ function collectFiles(dir, results) { return results; } -function extractSkillNames(content) { +function extractSkillNamesHyphen(content) { + const names = new Set(); + const rx = /Skill\(skill=['"]gsd-([a-z0-9-]+)['"]/gi; + let m; + while ((m = rx.exec(content)) !== null) names.add('gsd-' + m[1]); + return names; +} + +function extractSkillNamesColon(content) { const names = new Set(); const rx = /Skill\(skill=['"]gsd:([a-z0-9-]+)['"]/gi; let m; @@ -45,28 +56,58 @@ function extractSkillNames(content) { return names; } -describe('skill frontmatter name parity (#2643)', () => { - test('skillFrontmatterName helper emits colon form', () => { +describe('skill frontmatter name parity (#2643 / #2808)', () => { + test('skillFrontmatterName helper emits hyphen form (#2808)', () => { assert.strictEqual(typeof skillFrontmatterName, 'function'); - assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd:execute-phase'); - assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd:plan-phase'); - assert.strictEqual(skillFrontmatterName('gsd:next'), 'gsd:next'); + assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase'); + assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); + assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next'); }); - test('convertClaudeCommandToClaudeSkill emits name: gsd:', () => { + test('convertClaudeCommandToClaudeSkill emits name: gsd- (hyphen)', () => { const input = '---\nname: old\ndescription: test\n---\n\nBody.'; const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); - assert.match(result, /^---\nname: gsd:execute-phase\n/); + // Parse the frontmatter block structurally: extract the name: field value. + const frontmatterMatch = result.match(/^---\n([\s\S]*?)\n---/); + assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); + const frontmatterLines = frontmatterMatch[1].split('\n'); + const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); + assert.ok(nameEntry, 'frontmatter must contain a name: field'); + const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); + assert.strictEqual( + nameValue, + 'gsd-execute-phase', + `frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'` + ); }); - test('every workflow Skill(skill="gsd:") resolves to an emitted skill name', () => { + test('no workflow uses deprecated Skill(skill="gsd:") colon form', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const colonRefs = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNamesColon(src)) { + colonRefs.push(path.basename(f) + ': ' + n); + } + } + assert.deepStrictEqual( + colonRefs, + [], + 'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ') + ); + }); + + test('every workflow Skill(skill="gsd-") resolves to an emitted skill name', () => { const workflowFiles = collectFiles(WORKFLOWS_DIR); const referenced = new Set(); for (const f of workflowFiles) { const src = fs.readFileSync(f, 'utf-8'); - for (const n of extractSkillNames(src)) referenced.add(n); + for (const n of extractSkillNamesHyphen(src)) referenced.add(n); } - assert.ok(referenced.size > 0, 'expected at least one Skill(skill="gsd:") reference'); + assert.ok( + referenced.size > 0, + `expected at least one Skill(skill="gsd-") reference in workflows under ${WORKFLOWS_DIR}` + ); const emitted = new Set(); const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs new file mode 100644 index 000000000..66b78ddb2 --- /dev/null +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -0,0 +1,116 @@ +/** + * Regression test for bug #2808 + * + * All 85 GSD SKILL.md files declared `name: gsd:` (colon), the deprecated + * form. Claude Code surfaces the `name:` frontmatter field in autocomplete, so + * users saw `/gsd:add-phase` suggestions instead of the canonical `/gsd-add-phase`. + * + * Root cause: skillFrontmatterName() in bin/install.js converted hyphenated + * skill dir names to colon form (gsd-add-phase → gsd:add-phase) because + * workflows called Skill(skill="gsd:"). That was the original fix for + * #2643. Since then, workflows have been updated to use hyphen form (#2808). + * + * Fix: skillFrontmatterName() now returns the hyphen form unchanged. + * Four workflow Skill() colon calls updated to hyphen. + * + * This test verifies: + * 1. skillFrontmatterName returns hyphen form (not colon). + * 2. Installed SKILL.md would emit name: gsd- (not gsd:). + * 3. No workflow contains a Skill(skill="gsd:") colon call. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { convertClaudeCommandToClaudeSkill, skillFrontmatterName } = + require(path.join(ROOT, 'bin', 'install.js')); + +const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +function walkMd(dir) { + const files = []; + try { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) files.push(...walkMd(full)); + else if (e.name.endsWith('.md')) files.push(full); + } + } catch (err) { + assert.fail(`failed to read markdown files from ${dir}: ${err.message}`); + } + return files; +} + +describe('bug-2808: SKILL.md name: uses hyphen form', () => { + test('skillFrontmatterName returns hyphen form (not colon)', () => { + assert.strictEqual(skillFrontmatterName('gsd-add-phase'), 'gsd-add-phase'); + assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); + assert.strictEqual(skillFrontmatterName('gsd-autonomous'), 'gsd-autonomous'); + }); + + test('generated SKILL.md contains name: gsd- (not gsd:)', () => { + const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'expected GSD command files'); + + for (const cmd of cmdFiles) { + const base = cmd.replace(/\.md$/, ''); + const skillDirName = 'gsd-' + base; + const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8'); + const skillContent = convertClaudeCommandToClaudeSkill(src, skillDirName); + + // Parse frontmatter structurally: extract name: line from the --- block. + const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/); + assert.ok(fmMatch, `${cmd}: generated skill content must have a frontmatter block`); + const fmLines = fmMatch[1].split('\n'); + const nameEntry = fmLines.find((l) => l.startsWith('name:')); + assert.ok(nameEntry, `${cmd}: generated SKILL.md is missing required name: field`); + + const name = nameEntry.replace(/^name:\s*/, '').trim(); + assert.ok( + !name.includes(':'), + `${cmd}: SKILL.md name should be hyphen form, got "${name}"` + ); + assert.ok( + name.startsWith('gsd-'), + `${cmd}: SKILL.md name should start with gsd-, got "${name}"` + ); + } + }); + + test('no workflow contains Skill(skill="gsd:") colon form', () => { + const workflowFiles = walkMd(WORKFLOWS_DIR); + assert.ok( + workflowFiles.length > 0, + `expected workflow markdown files under ${WORKFLOWS_DIR}` + ); + const colonCalls = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + // Strip HTML comments to avoid matching commented-out examples. + const stripped = src.replace(//g, ''); + // Scan each line for Skill() calls using the colon form. + // Parsing line-by-line is more precise than a multi-line regex + // and avoids false positives from incidental matches in prose. + for (const line of stripped.split('\n')) { + const colonCallRe = /Skill\(skill=['"]gsd:([a-z0-9-]+)['"]/gi; + let m; + while ((m = colonCallRe.exec(line)) !== null) { + colonCalls.push(`${path.basename(f)}: Skill(skill="gsd:${m[1]}")`); + } + } + } + assert.deepStrictEqual( + colonCalls, + [], + 'deprecated colon-form Skill() calls found — update to gsd-: ' + colonCalls.join(', ') + ); + }); +}); diff --git a/tests/claude-skills-migration.test.cjs b/tests/claude-skills-migration.test.cjs index 64c360234..508918e5d 100644 --- a/tests/claude-skills-migration.test.cjs +++ b/tests/claude-skills-migration.test.cjs @@ -69,7 +69,7 @@ describe('convertClaudeCommandToClaudeSkill', () => { ); }); - test('emits colon-form name (gsd:) from hyphen-form dir (#2643)', () => { + test('emits hyphen-form name (gsd-) from hyphen-form dir (#2808)', () => { const input = [ '---', 'name: gsd:next', @@ -80,9 +80,9 @@ describe('convertClaudeCommandToClaudeSkill', () => { ].join('\n'); // Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is - // gsd:next (colon) so Claude Code resolves `/gsd:next` against the skill. + // gsd-next (hyphen, #2808) so Claude Code autocomplete shows canonical form. const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); - assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form'); + assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)'); }); test('preserves body content unchanged', () => { diff --git a/tests/qwen-skills-migration.test.cjs b/tests/qwen-skills-migration.test.cjs index f226898b8..683e4aa9e 100644 --- a/tests/qwen-skills-migration.test.cjs +++ b/tests/qwen-skills-migration.test.cjs @@ -66,7 +66,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => { ); }); - test('emits colon-form name (gsd:) from hyphen-form dir (#2643)', () => { + test('emits hyphen-form name (gsd-) from hyphen-form dir (#2808)', () => { const input = [ '---', 'name: gsd:next', @@ -77,9 +77,9 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => { ].join('\n'); // Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is - // gsd:next (colon) so Claude Code resolves `/gsd:next` against the skill. + // gsd-next (hyphen, #2808 — canonical invocation form for Claude Code autocomplete). const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); - assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form'); + assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)'); }); test('preserves body content unchanged', () => { @@ -154,7 +154,7 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => { // Verify content const content = fs.readFileSync(skillPath, 'utf8'); - assert.ok(content.includes('name: gsd:quick'), 'frontmatter name uses colon form (#2643)'); + assert.ok(content.includes('name: gsd-quick'), 'frontmatter name uses hyphen form (#2808)'); assert.ok(content.includes('description:'), 'description present'); assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved'); assert.ok(content.includes(''), 'body content preserved'); @@ -274,7 +274,7 @@ describe('Qwen Code: SKILL.md format validation', () => { assert.ok(fmMatch, 'has frontmatter block'); const fmLines = fmMatch[1].split('\n'); - const hasName = fmLines.some(l => l.startsWith('name: gsd:review')); + const hasName = fmLines.some(l => l.startsWith('name: gsd-review')); const hasDesc = fmLines.some(l => l.startsWith('description:')); const hasAgent = fmLines.some(l => l.startsWith('agent:')); const hasTools = fmLines.some(l => l.startsWith('allowed-tools:'));