From 90b33d050c80aa3e04647f719a05a4b444611993 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 9 May 2026 07:32:31 -0400 Subject: [PATCH] fix(phase): unify phase-dir naming via shared helper across creation paths (#3287) (#3292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: phase-dir prefix parity across creation paths (#3287 RED) Add failing tests asserting that init.phase-op and init.plan-phase expose expected_phase_dir with the project_code prefix when the phase directory does not yet exist — matching the prefix applied by phase.add. Co-Authored-By: Claude Sonnet 4.6 * fix(phase): unify phase-dir naming via shared getPhaseDirName helper (#3287) Both init.phase-op (discuss-phase workflow) and init.plan-phase (plan-phase workflow) now compute expected_phase_dir — the canonical directory name including the project_code prefix when set — and expose it in their JSON bundle. Workflow fallback mkdir calls are updated to use ${expected_phase_dir} instead of constructing the path from padded_phase + phase_slug, which was missing the project_code prefix. This eliminates the two-headed naming convention where phase.add/insert produced XR-01-foundation/ while the first-touch paths produced 01-foundation/ for the same project. Co-Authored-By: Claude Sonnet 4.6 * refactor(phase): apply project_code prefix in scaffold phase-dir (#3287) Audit finding: phase.scaffold (CJS commands.cjs + SDK phase-lifecycle.ts) also constructed phase dir names without project_code prefix. Both implementations now read config.project_code and apply the same prefix logic as phase.add/phase.insert. Co-Authored-By: Claude Sonnet 4.6 * changeset: pr=3292 for #3287 Co-Authored-By: Claude Sonnet 4.6 * docs(changelog): add entry for #3287 phase-dir prefix parity fix Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/witty-geese-purr.md | 5 + CHANGELOG.md | 1 + get-shit-done/bin/lib/commands.cjs | 6 +- get-shit-done/bin/lib/init.cjs | 52 ++++- get-shit-done/workflows/discuss-phase.md | 6 +- get-shit-done/workflows/plan-phase.md | 6 +- sdk/src/query/init.ts | 88 ++++++-- sdk/src/query/phase-lifecycle.ts | 10 +- .../bug-3287-phase-dir-prefix-parity.test.cjs | 198 ++++++++++++++++++ 9 files changed, 339 insertions(+), 33 deletions(-) create mode 100644 .changeset/witty-geese-purr.md create mode 100644 tests/bug-3287-phase-dir-prefix-parity.test.cjs diff --git a/.changeset/witty-geese-purr.md b/.changeset/witty-geese-purr.md new file mode 100644 index 000000000..fc7e27495 --- /dev/null +++ b/.changeset/witty-geese-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3292 +--- +**`/gsd-discuss-phase` and `/gsd-plan-phase` first-touch creation now apply `project_code` prefix consistently with `phase.add`/`phase.insert`** — projects with `project_code` set in `.planning/config.json` no longer accumulate a two-headed naming convention (`01-foundation/` mixed with `XR-02.1-spike/`). Routes all phase-directory creation through a single shared `getPhaseDirName` helper to prevent future drift. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8152727..025013e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`/gsd-discuss-phase` and `/gsd-plan-phase` first-touch creation now apply `project_code` prefix consistently with `phase.add`/`phase.insert`** — projects with `project_code` set in `.planning/config.json` no longer accumulate a two-headed naming convention (`01-foundation/` mixed with `XR-02.1-spike/`). `init.phase-op` and `init.plan-phase` now expose `expected_phase_dir` (with prefix) in their JSON bundle; workflow fallback mkdir calls use this value instead of constructing the path from `padded_phase`+`phase_slug`. `phase.scaffold phase-dir` (CJS and SDK) also fixed. (#3287) - **`buildStateFrontmatter` now counts nested `plans/-PLAN--.md` files** — repos using the nested layout (post-#3139) no longer get `progress.*` counters silently overwritten downward on every state mutation. Sibling fix to #3115/#3139/#3191. (#3261) ## [1.41.0](https://github.com/gsd-build/get-shit-done/compare/v1.40.0...v1.41.0) - 2026-05-07 diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 2127ef8b7..a08d9fd9b 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -792,7 +792,11 @@ function cmdScaffold(cwd, type, options, raw) { error('phase and name required for phase-dir scaffold'); } const slug = generateSlugInternal(name); - const dirName = `${padded}-${slug}`; + // #3287: apply project_code prefix to stay consistent with phase.add/phase.insert + const scaffoldConfig = loadConfig(cwd); + const scaffoldProjectCode = scaffoldConfig.project_code || ''; + const scaffoldPrefix = scaffoldProjectCode ? `${scaffoldProjectCode}-` : ''; + const dirName = `${scaffoldPrefix}${padded}-${slug}`; const phasesParent = planningPaths(cwd).phases; fs.mkdirSync(phasesParent, { recursive: true }); const dirPath = path.join(phasesParent, dirName); diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index d79ce60e0..9c9d92b4b 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -272,6 +272,23 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) { : null; const phase_req_ids = (reqExtracted && reqExtracted !== 'TBD') ? reqExtracted : null; + // #3287: compute the canonical directory name with project_code prefix so + // the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add. + const phaseDirPlan = phaseInfo?.directory || null; + const phaseNumberPlan = phaseInfo?.phase_number || null; + const phaseNamePlan = phaseInfo?.phase_name || null; + const rawProjectCodePlan = config.project_code || ''; + let expectedPhaseDirPlan = null; + if (!phaseDirPlan && phaseNumberPlan && phaseNamePlan) { + const paddedNum = normalizePhaseName(phaseNumberPlan); + const slug = generateSlugInternal(phaseNamePlan).substring(0, 60); + if (slug) { + const prefix = rawProjectCodePlan ? `${rawProjectCodePlan}-` : ''; + const dirName = `${prefix}${paddedNum}-${slug}`; + expectedPhaseDirPlan = toPosixPath(path.relative(cwd, path.join(planningPaths(cwd).phases, dirName))); + } + } + const result = { // Models researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'), @@ -294,11 +311,12 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) { // Phase info phase_found: !!phaseInfo, - phase_dir: phaseInfo?.directory || null, - phase_number: phaseInfo?.phase_number || null, - phase_name: phaseInfo?.phase_name || null, + phase_dir: phaseDirPlan, + expected_phase_dir: expectedPhaseDirPlan, + phase_number: phaseNumberPlan, + phase_name: phaseNamePlan, phase_slug: phaseInfo?.phase_slug || null, - padded_phase: phaseInfo?.phase_number ? normalizePhaseName(phaseInfo.phase_number) : null, + padded_phase: phaseNumberPlan ? normalizePhaseName(phaseNumberPlan) : null, phase_req_ids, // Existing artifacts @@ -747,6 +765,23 @@ function cmdInitPhaseOp(cwd, phase, raw) { } } + // #3287: compute the canonical directory name with project_code prefix so + // the first-touch mkdir in /gsd-discuss-phase stays consistent with phase.add. + const phaseDir = phaseInfo?.directory || null; + const phaseNumber = phaseInfo?.phase_number || null; + const phaseName = phaseInfo?.phase_name || null; + const rawProjectCode = config.project_code || ''; + let expectedPhaseDir = null; + if (!phaseDir && phaseNumber && phaseName) { + const paddedNum = normalizePhaseName(phaseNumber); + const slug = generateSlugInternal(phaseName).substring(0, 60); + if (slug) { + const prefix = rawProjectCode ? `${rawProjectCode}-` : ''; + const dirName = `${prefix}${paddedNum}-${slug}`; + expectedPhaseDir = toPosixPath(path.relative(cwd, path.join(planningPaths(cwd).phases, dirName))); + } + } + const result = { // Config commit_docs: config.commit_docs, @@ -760,11 +795,12 @@ function cmdInitPhaseOp(cwd, phase, raw) { // Phase info phase_found: !!phaseInfo, - phase_dir: phaseInfo?.directory || null, - phase_number: phaseInfo?.phase_number || null, - phase_name: phaseInfo?.phase_name || null, + phase_dir: phaseDir, + expected_phase_dir: expectedPhaseDir, + phase_number: phaseNumber, + phase_name: phaseName, phase_slug: phaseInfo?.phase_slug || null, - padded_phase: phaseInfo?.phase_number ? normalizePhaseName(phaseInfo.phase_number) : null, + padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, // Existing artifacts has_research: phaseInfo?.has_research || false, diff --git a/get-shit-done/workflows/discuss-phase.md b/get-shit-done/workflows/discuss-phase.md index d1bf050b2..a79f48ffd 100644 --- a/get-shit-done/workflows/discuss-phase.md +++ b/get-shit-done/workflows/discuss-phase.md @@ -373,11 +373,13 @@ DISCUSSION-LOG.md is for human reference only (audits, retrospectives) and is NO **Find or create phase directory:** -Use values from init: `phase_dir`, `phase_slug`, `padded_phase`. If `phase_dir` is null: +Use values from init: `phase_dir`, `expected_phase_dir`, `phase_slug`, `padded_phase`. If `phase_dir` is null: ```bash -mkdir -p ".planning/phases/${padded_phase}-${phase_slug}" +mkdir -p "${expected_phase_dir}" ``` +Set `phase_dir="${expected_phase_dir}"` after creation. + **File location:** `${phase_dir}/${padded_phase}-CONTEXT.md` **Read the CONTEXT.md template now (lazy-loaded):** diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index f7381807c..049c00811 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -108,11 +108,13 @@ Extract `--prd ` from $ARGUMENTS. If present, set PRD_FILE to the file **If no phase number:** Detect next unplanned phase from roadmap. -**If `phase_found` is false:** Validate phase exists in ROADMAP.md. If valid, create the directory using `phase_slug` and `padded_phase` from init: +**If `phase_found` is false:** Validate phase exists in ROADMAP.md. If valid, create the directory using `expected_phase_dir` from init (includes `project_code` prefix when set): ```bash -mkdir -p ".planning/phases/${padded_phase}-${phase_slug}" +mkdir -p "${expected_phase_dir}" ``` +Set `phase_dir="${expected_phase_dir}"` after creation. + **Existing artifacts from init:** `has_research`, `has_plans`, `plan_count`. Set `CHUNKED_MODE` from flag or config: diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index 0ffd334b8..6071754ca 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -29,6 +29,7 @@ import { maskIfSecret } from './secrets.js'; import { findPhase } from './phase.js'; import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js'; import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js'; +import { generatePhaseSlug, assertSafeProjectCode } from './phase-lifecycle-policy.js'; import type { QueryHandler } from './utils.js'; // ─── Internal helpers ────────────────────────────────────────────────────── @@ -60,6 +61,27 @@ function pathExists(base: string, relPath: string): boolean { return existsSync(join(base, relPath)); } +/** + * Compute the canonical phase directory name for a known phase entry from the + * roadmap when no directory exists yet. Applies the project_code prefix so + * the first-touch creation path used by /gsd-discuss-phase and /gsd-plan-phase + * stays consistent with the prefix produced by `phase.add` / `phase.insert`. + * + * Returns null when phaseNumber or phaseName cannot be determined. + */ +function computeExpectedPhaseDirName( + phaseNumber: string | null, + phaseName: string | null, + projectCode: string, +): string | null { + if (!phaseNumber || !phaseName) return null; + const paddedNum = normalizePhaseName(phaseNumber); + const slug = generatePhaseSlug(phaseName); + if (!slug) return null; + const prefix = projectCode ? `${projectCode}-` : ''; + return `${prefix}${paddedNum}-${slug}`; +} + /** * Get the latest completed milestone from MILESTONES.md. * Port of getLatestCompletedMilestone from init.cjs lines 10-25. @@ -377,8 +399,21 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) : ['', '', '']; const phaseNumber = (phaseInfo?.phase_number as string) || null; + const phaseName = (phaseInfo?.phase_name as string) ?? null; + const phaseDir = (phaseInfo?.directory as string) ?? null; const plans = (phaseInfo?.plans || []) as string[]; + // #3287: compute the canonical directory name with project_code prefix so + // the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add. + const rawProjectCode = (config as Record).project_code as string || ''; + assertSafeProjectCode(rawProjectCode); + const expectedPhaseDirName = phaseDir + ? null // directory already exists — no need to create + : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); + const expectedPhaseDir = expectedPhaseDirName + ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) + : null; + const cfg = config as GSDConfig; const result: Record = { researcher_model: researcherModel, @@ -394,9 +429,10 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) auto_chain_active: !!config.workflow._auto_chain_active, mode: cfg.mode ?? 'interactive', phase_found: !!phaseInfo, - phase_dir: (phaseInfo?.directory as string) ?? null, + phase_dir: phaseDir, + expected_phase_dir: expectedPhaseDir, phase_number: phaseNumber, - phase_name: (phaseInfo?.phase_name as string) ?? null, + phase_name: phaseName, phase_slug: (phaseInfo?.phase_slug as string) ?? null, padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, phase_req_ids, @@ -414,22 +450,22 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) }; // Add artifact paths if phase directory exists - if (phaseInfo?.directory) { - const phaseDirFull = join(projectDir, phaseInfo.directory as string); + if (phaseDir) { + const phaseDirFull = join(projectDir, phaseDir); try { const files = readdirSync(phaseDirFull); const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) result.context_path = toPosixPath(join(phaseInfo.directory as string, contextFile)); + if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) result.research_path = toPosixPath(join(phaseInfo.directory as string, researchFile)); + if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) result.verification_path = toPosixPath(join(phaseInfo.directory as string, verificationFile)); + if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) result.uat_path = toPosixPath(join(phaseInfo.directory as string, uatFile)); + if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); - if (reviewsFile) result.reviews_path = toPosixPath(join(phaseInfo.directory as string, reviewsFile)); + if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); const patternsFile = files.find(f => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md'); - if (patternsFile) result.patterns_path = toPosixPath(join(phaseInfo.directory as string, patternsFile)); + if (patternsFile) result.patterns_path = toPosixPath(join(phaseDir, patternsFile)); } catch { /* intentionally empty */ } } @@ -681,8 +717,21 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => const phaseFound = !!(phaseInfo && phaseInfo.found); const phaseNumber = (phaseInfo?.phase_number as string) || null; + const phaseName = (phaseInfo?.phase_name as string) ?? null; + const phaseDir = (phaseInfo?.directory as string) ?? null; const plans = (phaseInfo?.plans || []) as string[]; + // #3287: compute the canonical directory name with project_code prefix so + // the first-touch mkdir in /gsd-discuss-phase stays consistent with phase.add. + const rawProjectCode = (config as Record).project_code as string || ''; + assertSafeProjectCode(rawProjectCode); + const expectedPhaseDirName = phaseDir + ? null // directory already exists — no need to create + : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); + const expectedPhaseDir = expectedPhaseDirName + ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) + : null; + const result: Record = { commit_docs: config.commit_docs, // #2997: secret config keys (brave_search, firecrawl, exa_search) may be @@ -694,9 +743,10 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => firecrawl: typeof config.firecrawl === 'string' ? maskIfSecret('firecrawl', config.firecrawl) : config.firecrawl, exa_search: typeof config.exa_search === 'string' ? maskIfSecret('exa_search', config.exa_search) : config.exa_search, phase_found: phaseFound, - phase_dir: (phaseInfo?.directory as string) ?? null, + phase_dir: phaseDir, + expected_phase_dir: expectedPhaseDir, phase_number: phaseNumber, - phase_name: (phaseInfo?.phase_name as string) ?? null, + phase_name: phaseName, phase_slug: (phaseInfo?.phase_slug as string) ?? null, padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, has_research: (phaseInfo?.has_research as boolean) || false, @@ -713,20 +763,20 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => }; // Add artifact paths if phase directory exists - if (phaseInfo?.directory) { - const phaseDirFull = join(projectDir, phaseInfo.directory as string); + if (phaseDir) { + const phaseDirFull = join(projectDir, phaseDir); try { const files = readdirSync(phaseDirFull); const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) result.context_path = toPosixPath(join(phaseInfo.directory as string, contextFile)); + if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) result.research_path = toPosixPath(join(phaseInfo.directory as string, researchFile)); + if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) result.verification_path = toPosixPath(join(phaseInfo.directory as string, verificationFile)); + if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) result.uat_path = toPosixPath(join(phaseInfo.directory as string, uatFile)); + if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); - if (reviewsFile) result.reviews_path = toPosixPath(join(phaseInfo.directory as string, reviewsFile)); + if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); } catch { /* intentionally empty */ } } diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index bf16440e6..190ca2c27 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -539,7 +539,15 @@ export const phaseScaffold: QueryHandler = async (args, projectDir, workstream) throw new GSDError('phase and name required for phase-dir scaffold', ErrorClassification.Validation); } const slug = generatePhaseSlug(name); - const dirNameNew = `${padded}-${slug}`; + // #3287: apply project_code prefix to stay consistent with phase.add/phase.insert + let scaffoldConfig: Record = {}; + try { + scaffoldConfig = JSON.parse(await readFile(planningPaths(projectDir, workstream).config, 'utf-8')); + } catch { /* use defaults */ } + const scaffoldProjectCode = (scaffoldConfig.project_code as string) || ''; + assertSafeProjectCode(scaffoldProjectCode); + const scaffoldPrefix = scaffoldProjectCode ? `${scaffoldProjectCode}-` : ''; + const dirNameNew = `${scaffoldPrefix}${padded}-${slug}`; assertSafePhaseDirName(dirNameNew, 'scaffold phase directory'); const phasesParent = planningPaths(projectDir, workstream).phases; await mkdir(phasesParent, { recursive: true }); diff --git a/tests/bug-3287-phase-dir-prefix-parity.test.cjs b/tests/bug-3287-phase-dir-prefix-parity.test.cjs new file mode 100644 index 000000000..ffe52fe61 --- /dev/null +++ b/tests/bug-3287-phase-dir-prefix-parity.test.cjs @@ -0,0 +1,198 @@ +'use strict'; +/** + * Regression test for #3287 — phase-dir prefix parity across creation paths. + * + * Projects with `project_code` set in `.planning/config.json` must get the + * same `--` directory shape from ALL phase-creation paths, + * not just from `phase.add` / `phase.insert`. + * + * Three tests: + * A — sanity: `phase.add` emits the prefixed dir (already works). + * B — init phase-op exposes `expected_phase_dir` with the prefix when + * the directory does not yet exist (first-touch path for /gsd-discuss-phase). + * C — init plan-phase exposes `expected_phase_dir` with the prefix when + * the directory does not yet exist (first-touch path for /gsd-plan-phase). + * + * Tests B and C are RED until the fix lands. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ─── shared fixture ────────────────────────────────────────────────────────── + +function makeXRProject(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'XR' }), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap v1.0', + '', + '### Phase 1: Foundation', + '**Goal:** Setup project', + '**Plans:** 0 plans', + '', + '---', + '', + ].join('\n'), + ); +} + +// ─── Test A — sanity: phase.add honours project_code ───────────────────────── + +describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('phase.add creates XR-02- when project_code is XR', () => { + makeXRProject(tmpDir); + + const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `phase.add failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_number, 2, 'phase number should be 2'); + + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + const dirs = fs.readdirSync(phasesDir); + const prefixedDirs = dirs.filter(d => d.startsWith('XR-')); + assert.ok( + prefixedDirs.length > 0, + `Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`, + ); + assert.ok( + dirs.some(d => d === 'XR-02-auth-service'), + `Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`, + ); + }); +}); + +// ─── Test B — init phase-op exposes expected_phase_dir with prefix ──────────── + +describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { + makeXRProject(tmpDir); + + // Phase 1 is in the roadmap but has no directory yet — the first-touch path + const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init phase-op failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); + assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); + + // The fix: expected_phase_dir must carry the project_code prefix + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + assert.ok( + output.expected_phase_dir.includes('XR-'), + `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, + ); + assert.ok( + output.expected_phase_dir.includes('foundation'), + `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, + ); + }); + + test('expected_phase_dir is null when no project_code is set', () => { + // No project_code — expected_phase_dir should still be present but without prefix + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', + ); + + const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init phase-op failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_dir, null); + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + // Without project_code, should have no prefix — just NN-slug + assert.ok( + !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), + `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, + ); + }); +}); + +// ─── Test C — init plan-phase exposes expected_phase_dir with prefix ────────── + +describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { + makeXRProject(tmpDir); + + // Phase 1 is in the roadmap but has no directory yet — the first-touch path + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); + assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); + + // The fix: expected_phase_dir must carry the project_code prefix + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + assert.ok( + output.expected_phase_dir.includes('XR-'), + `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, + ); + assert.ok( + output.expected_phase_dir.includes('foundation'), + `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, + ); + }); + + test('expected_phase_dir omits prefix when project_code is not set', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', + ); + + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_dir, null); + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + assert.ok( + !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), + `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, + ); + }); +});