fix(phase-dir): apply project_code prefix in plan-milestone-gaps, import, and add-backlog workflows (PRED.k015) (#3306)

* test(phase-dir): add red test for k015 prefix-drift in plan-milestone-gaps and import workflows (#3298)

Asserts that:
- plan-milestone-gaps.md step 8 does not use bare {NN}-{name} mkdir pattern
- plan-milestone-gaps.md step 8 uses phase.add or expected_phase_dir
- import.md plan_convert does not use bare {NN}-{slug} mkdir pattern
- import.md plan_convert uses expected_phase_dir from init.phase-op

These 5 tests are RED until the fix lands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-dir): add projectCode prefix to phase-dir construction in plan-milestone-gaps and import workflows (#3298)

Both plan-milestone-gaps.md step 8 and import.md plan_convert step were
constructing phase directories using raw {NN}-{name}/{NN}-{slug} template
patterns, bypassing the project_code prefix from .planning/config.json.

Fix: both steps now call `gsd-sdk query init.phase-op <N>` and consume the
`expected_phase_dir` field (which includes the `<CODE>-<NN>-<slug>` prefix
when project_code is set), matching the pattern already established by
PR #3292 for /gsd-discuss-phase and /gsd-plan-phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-dir): apply project_code prefix to backlog phase dir in add-backlog workflow (k015 sibling, #3298)

Sibling k015 audit found a third drift site: add-backlog.md step 4 was
constructing the 999.x backlog phase directory using raw ${NEXT}-${SLUG}
without applying the project_code prefix from .planning/config.json.

Fix: read project_code via `gsd-sdk query config-get project_code --raw`
and prepend `${CODE}-` when set, matching the pattern used by phase.insert
(which already applies project_code to decimal phases in phase.cjs line 736).

Also extends bug-3298 regression test to cover this third site.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changeset): add changeset for #3298 phase-dir prefix drift fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changeset): set pr: 3306 in changeset for #3298; resolve stash conflict in live-command-registry.cjs

The conflict was cosmetic (string concat → template literals) introduced by
accidental git stash during test verification. Taking the newer template-literal
form throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-09 11:47:15 -04:00
committed by GitHub
parent 04031244b8
commit 370033e907
6 changed files with 217 additions and 24 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.
</step>

View File

@@ -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

View File

@@ -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 `<CODE>-<NN>-<slug>` 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',
);
});
});

View File

@@ -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:<slug> or gsd-<slug>
// 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:<slug>" or "name: gsd-<slug>")'
`[live-command-registry] ${filePath}: could not extract slug from frontmatter ` +
`(expected "name: gsd:<slug>" or "name: gsd-<slug>")`
);
}
@@ -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;