diff --git a/.changeset/3298-phase-dir-prefix-drift-workflows.md b/.changeset/3298-phase-dir-prefix-drift-workflows.md new file mode 100644 index 000000000..7add4fe6d --- /dev/null +++ b/.changeset/3298-phase-dir-prefix-drift-workflows.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3306 +--- +**Phase directories in `/gsd-plan-milestone-gaps`, `/gsd-import`, and `/gsd-capture --backlog` now honour `project_code` prefix** — three workflow files were constructing phase directory paths using raw `{NN}-{slug}` patterns, bypassing the `project_code` prefix from `.planning/config.json`. In a project with `project_code: "XR"`, these workflows created `06-fix-auth/` instead of `XR-06-fix-auth/`, while `/gsd-plan-phase` and `/gsd-discuss-phase` (fixed in #3292) correctly produced the prefixed form. All three paths now resolve the directory name via `gsd-sdk query init.phase-op` (plan-milestone-gaps, import) or read `project_code` via `config-get` (add-backlog), consistent with the PRED.k015 requirement that project_code prefix is applied at all consumers. (#3298) diff --git a/get-shit-done/workflows/add-backlog.md b/get-shit-done/workflows/add-backlog.md index 7ea896b29..4b2ca973e 100644 --- a/get-shit-done/workflows/add-backlog.md +++ b/get-shit-done/workflows/add-backlog.md @@ -49,16 +49,21 @@ Plans: ## Step 4: Create the phase directory +Apply the `project_code` prefix (if set in `.planning/config.json`) so the backlog directory name is consistent with all other phase-creation paths: + ```bash SLUG=$(gsd-sdk query generate-slug "$ARGUMENTS" --raw) -mkdir -p ".planning/phases/${NEXT}-${SLUG}" -touch ".planning/phases/${NEXT}-${SLUG}/.gitkeep" +PROJECT_CODE=$(gsd-sdk query config-get project_code --raw 2>/dev/null || echo "") +PREFIX=$([ -n "$PROJECT_CODE" ] && echo "${PROJECT_CODE}-" || echo "") +PHASE_DIR=".planning/phases/${PREFIX}${NEXT}-${SLUG}" +mkdir -p "${PHASE_DIR}" +touch "${PHASE_DIR}/.gitkeep" ``` ## Step 5: Commit ```bash -gsd-sdk query commit "docs: add backlog item ${NEXT} — ${ARGUMENTS}" --files .planning/ROADMAP.md ".planning/phases/${NEXT}-${SLUG}/.gitkeep" +gsd-sdk query commit "docs: add backlog item ${NEXT} — ${ARGUMENTS}" --files .planning/ROADMAP.md "${PHASE_DIR}/.gitkeep" ``` ## Step 6: Report @@ -67,7 +72,7 @@ gsd-sdk query commit "docs: add backlog item ${NEXT} — ${ARGUMENTS}" --files . ## 📋 Backlog Item Added Phase {NEXT}: {description} -Directory: .planning/phases/{NEXT}-{slug}/ +Directory: {PHASE_DIR}/ This item lives in the backlog parking lot. Use /gsd-discuss-phase {NEXT} to explore it further. diff --git a/get-shit-done/workflows/import.md b/get-shit-done/workflows/import.md index 04f51e695..a2bbcb2c3 100644 --- a/get-shit-done/workflows/import.md +++ b/get-shit-done/workflows/import.md @@ -173,16 +173,21 @@ Apply GSD naming convention for the output filename: - NEVER use `PLAN-01.md`, `plan-01.md`, or any other format - NN = phase number (zero-padded), MM = plan number within the phase (zero-padded) -Determine the target directory: -``` -.planning/phases/{NN}-{slug}/ +Determine the target directory by querying `init.phase-op` for the phase number extracted in `plan_read_input`. This ensures the `project_code` prefix from `.planning/config.json` is applied: + +```bash +INIT=$(gsd-sdk query init.phase-op "{NN}") +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi +expected_phase_dir=$(echo "$INIT" | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).expected_phase_dir)") ``` If the directory does not exist, create it: ```bash -mkdir -p ".planning/phases/{NN}-{slug}/" +mkdir -p "${expected_phase_dir}" ``` +Set `phase_dir="${expected_phase_dir}"` for use in subsequent steps. + Write the PLAN.md file to the target directory. diff --git a/get-shit-done/workflows/plan-milestone-gaps.md b/get-shit-done/workflows/plan-milestone-gaps.md index 7f05c2f07..bcf191fe9 100644 --- a/get-shit-done/workflows/plan-milestone-gaps.md +++ b/get-shit-done/workflows/plan-milestone-gaps.md @@ -139,10 +139,17 @@ grep -c "Pending" .planning/REQUIREMENTS.md ## 8. Create Phase Directories +For each new phase (N, N+1, …), resolve the directory name via `init.phase-op` so the `project_code` prefix is honoured: + ```bash -mkdir -p ".planning/phases/{NN}-{name}" +INIT=$(gsd-sdk query init.phase-op "{NN}") +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi +expected_phase_dir=$(echo "$INIT" | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).expected_phase_dir)") +mkdir -p "${expected_phase_dir}" ``` +Repeat for each gap-closure phase number. This produces `{CODE}-{NN}-{slug}/` when `project_code` is set in `.planning/config.json`, and `{NN}-{slug}/` otherwise — consistent with all other phase-creation paths. + ## 9. Commit Roadmap and Requirements Update ```bash diff --git a/tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs b/tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs new file mode 100644 index 000000000..0026b2ddf --- /dev/null +++ b/tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs @@ -0,0 +1,171 @@ +'use strict'; +/** + * Regression test for #3298 — phase-dir prefix drift in /gsd-plan-milestone-gaps, + * /gsd-import, and /gsd-capture --backlog workflows (PRED.k015 sibling audit). + * + * Projects with `project_code` set in `.planning/config.json` must have + * consistent `--` directory naming across ALL phase-creation + * paths. PR #3292 (#3287) fixed `/gsd-discuss-phase` and `/gsd-plan-phase`. + * + * Missed sites (this PR): + * 1. `plan-milestone-gaps.md` step 8 — raw `{NN}-{name}` mkdir pattern. + * 2. `import.md` plan_convert step — raw `{NN}-{slug}` mkdir pattern. + * 3. `add-backlog.md` step 4 — raw `${NEXT}-${SLUG}` mkdir pattern + * (backlog uses 999.x numbering; still subject to project_code prefix). + * + * The fix: all three files must resolve the directory name via `init.phase-op` + * (which exposes `expected_phase_dir` with the project_code prefix) or use + * a `project_code`-aware helper before calling mkdir. + * + * Tests are structural (parse-level) — no source-grep on raw strings. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const PMG_WF = path.join( + __dirname, '..', 'get-shit-done', 'workflows', 'plan-milestone-gaps.md', +); +const IMPORT_WF = path.join( + __dirname, '..', 'get-shit-done', 'workflows', 'import.md', +); +const BACKLOG_WF = path.join( + __dirname, '..', 'get-shit-done', 'workflows', 'add-backlog.md', +); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function readWorkflow(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch (err) { + throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`); + } +} + +/** + * Returns true when the content contains a bare `mkdir -p ".planning/phases/{NN}-{name}"` + * or `mkdir -p ".planning/phases/{NN}-{slug}"` pattern that does NOT include + * a `project_code`/`expected_phase_dir` variable — i.e., the unfixed drift pattern. + * + * We look for `mkdir` lines that reference .planning/phases/ where the directory + * component starts with `{` (template literal, no variable substitution). + */ +function containsBareTemplateMkdir(content) { + // Match lines like: mkdir -p ".planning/phases/{NN}-{name}" + // or: mkdir -p ".planning/phases/{NN}-{slug}/" + // These are the drift patterns — they don't use expected_phase_dir. + return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content); +} + +/** + * Returns true when the content contains a bare shell-variable mkdir pattern like: + * mkdir -p ".planning/phases/${NEXT}-${SLUG}" + * without a project_code prefix variable before `${NEXT}` (or similar). + * + * The drift pattern is: directory path starts with `${NEXT}` (or `${NN}`) directly, + * with no preceding `${PREFIX}` or `${CODE}` variable that would carry project_code. + */ +function containsBareShellVarMkdir(content) { + // Match mkdir lines where the phases/ directory component starts with a bare + // shell variable like ${NEXT} or ${NN} — no prefix variable before it. + // Positive match: mkdir .../phases/${NEXT}- or .../phases/${NN}- + // We exclude lines that have a variable BEFORE ${NEXT}/${NN} (i.e., a prefix var). + return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content) + || /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content); +} + +// ─── plan-milestone-gaps.md ─────────────────────────────────────────────────── + +describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => { + test('workflow file exists', () => { + assert.ok( + fs.existsSync(PMG_WF), + `plan-milestone-gaps.md must exist at ${PMG_WF}`, + ); + }); + + test('step 8 must not use bare {NN}-{name} mkdir pattern', () => { + const content = readWorkflow(PMG_WF); + assert.ok( + !containsBareTemplateMkdir(content), + 'plan-milestone-gaps.md must not contain bare mkdir .planning/phases/{NN}-{name} pattern — use phase.add or expected_phase_dir', + ); + }); + + test('step 8 must use expected_phase_dir or phase.add for directory creation', () => { + const content = readWorkflow(PMG_WF); + const usesExpectedPhaseDir = content.includes('expected_phase_dir'); + const usesPhaseAdd = content.includes('phase.add'); + assert.ok( + usesExpectedPhaseDir || usesPhaseAdd, + 'plan-milestone-gaps.md must use expected_phase_dir (from init.phase-op) or phase.add to create phase directories with project_code prefix', + ); + }); +}); + +// ─── import.md ─────────────────────────────────────────────────────────────── + +describe('bug-3298 — import.md must not construct bare {NN}-{slug} phase dirs', () => { + test('workflow file exists', () => { + assert.ok( + fs.existsSync(IMPORT_WF), + `import.md must exist at ${IMPORT_WF}`, + ); + }); + + test('plan_convert step must not use bare {NN}-{slug} mkdir pattern', () => { + const content = readWorkflow(IMPORT_WF); + assert.ok( + !containsBareTemplateMkdir(content), + 'import.md must not contain bare mkdir .planning/phases/{NN}-{slug} pattern — use expected_phase_dir from init.phase-op', + ); + }); + + test('plan_convert step must use expected_phase_dir for directory creation', () => { + const content = readWorkflow(IMPORT_WF); + assert.ok( + content.includes('expected_phase_dir'), + 'import.md must use expected_phase_dir (from init.phase-op) to create phase directory with project_code prefix', + ); + }); + + test('plan_convert step must call init.phase-op to resolve the prefixed dir', () => { + const content = readWorkflow(IMPORT_WF); + assert.ok( + content.includes('init.phase-op') || content.includes('init phase-op'), + 'import.md must call gsd-sdk query init.phase-op to get expected_phase_dir with project_code prefix', + ); + }); +}); + +// ─── add-backlog.md (sibling site found during k015 audit) ─────────────────── + +describe('bug-3298 — add-backlog.md must apply project_code prefix when creating 999.x dirs', () => { + test('workflow file exists', () => { + assert.ok( + fs.existsSync(BACKLOG_WF), + `add-backlog.md must exist at ${BACKLOG_WF}`, + ); + }); + + test('step 4 must not use bare ${NEXT}-${SLUG} mkdir without project_code prefix', () => { + const content = readWorkflow(BACKLOG_WF); + assert.ok( + !containsBareShellVarMkdir(content), + 'add-backlog.md must not create .planning/phases/${NEXT}-${SLUG} without a project_code prefix variable — apply ${PREFIX} (or equivalent) before ${NEXT}', + ); + }); + + test('step 4 must reference project_code or a prefix variable before the phase number', () => { + const content = readWorkflow(BACKLOG_WF); + const hasProjectCodeRef = content.includes('project_code') || content.includes('PROJECT_CODE'); + const hasPrefixVar = content.includes('${PREFIX}') || content.includes('${PHASE_PREFIX}') || content.includes('${CODE}'); + assert.ok( + hasProjectCodeRef || hasPrefixVar, + 'add-backlog.md must read project_code (or use a PREFIX variable) to apply the project_code prefix to the 999.x phase directory name', + ); + }); +}); diff --git a/tests/helpers/live-command-registry.cjs b/tests/helpers/live-command-registry.cjs index 2679b411b..004cfc9b4 100644 --- a/tests/helpers/live-command-registry.cjs +++ b/tests/helpers/live-command-registry.cjs @@ -46,16 +46,16 @@ let _cache = null; * introducing a YAML parser dependency would be disproportionate. * * Supported name forms: - * name: gsd:slug -> slug = "slug" - * name: gsd-slug -> slug = "slug" - * name: "gsd:slug" -> slug = "slug" (quoted) - * name: "gsd-slug" -> slug = "slug" (quoted) + * name: gsd:slug → slug = "slug" + * name: gsd-slug → slug = "slug" + * name: "gsd:slug" → slug = "slug" (quoted) + * name: "gsd-slug" → slug = "slug" (quoted) */ function parseSlug(content, filePath) { // Frontmatter must start with '---' on the very first line. if (!content.startsWith('---')) { throw new Error( - '[live-command-registry] ' + filePath + ': missing YAML frontmatter — file must start with \'---\'' + `[live-command-registry] ${filePath}: missing YAML frontmatter — file must start with '---'` ); } @@ -63,7 +63,7 @@ function parseSlug(content, filePath) { const closingIdx = content.indexOf('\n---', 3); if (closingIdx < 0) { throw new Error( - '[live-command-registry] ' + filePath + ': unclosed YAML frontmatter — no closing \'---\' found' + `[live-command-registry] ${filePath}: unclosed YAML frontmatter — no closing '---' found` ); } @@ -72,11 +72,11 @@ function parseSlug(content, filePath) { // Match `name:` line, allowing optional quotes around the value. // The value must be one of: gsd: or gsd- // where slug = [a-z0-9][a-z0-9-]* - const nameMatch = frontmatter.match(/^name:\s*"?(gsd[:-])([a-z0-9][a-z0-9-]*)"?\s*$/m); + const nameMatch = frontmatter.match(/^name:\s*"?(gsd[:‑-])([a-z0-9][a-z0-9-]*)"?\s*$/m); if (!nameMatch) { throw new Error( - '[live-command-registry] ' + filePath + ': could not extract slug from frontmatter ' + - '(expected "name: gsd:" or "name: gsd-")' + `[live-command-registry] ${filePath}: could not extract slug from frontmatter ` + + `(expected "name: gsd:" or "name: gsd-")` ); } @@ -96,12 +96,12 @@ function getLiveCommandTokens() { if (!fs.existsSync(COMMANDS_DIR)) { throw new Error( - '[live-command-registry] commands directory not found: ' + COMMANDS_DIR + `[live-command-registry] commands directory not found: ${COMMANDS_DIR}` ); } const entries = fs.readdirSync(COMMANDS_DIR) - .filter(function(f) { return f.endsWith('.md'); }) + .filter(f => f.endsWith('.md')) .sort(); // deterministic order for reproducible error messages const tokens = new Set(); @@ -113,16 +113,16 @@ function getLiveCommandTokens() { content = fs.readFileSync(filePath, 'utf-8'); } catch (err) { throw new Error( - '[live-command-registry] failed to read ' + filePath + ': ' + err.message + `[live-command-registry] failed to read ${filePath}: ${err.message}` ); } const slug = parseSlug(content, filePath); // Emit all three canonical token forms per slug. - tokens.add('/gsd-' + slug); // Claude / non-Gemini - tokens.add('/gsd:' + slug); // Gemini - tokens.add('$gsd-' + slug); // Codex + tokens.add(`/gsd-${slug}`); // Claude / non-Gemini + tokens.add(`/gsd:${slug}`); // Gemini + tokens.add(`$gsd-${slug}`); // Codex } _cache = tokens;