// allow-test-rule: source-text-is-the-product // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. // allow-test-rule: state-md-is-the-runtime-contract — regression tests for // bug #3517 assert the exact STATE.md fields written by phase.complete; // STATE.md IS the product surface being verified, not source code. // Migration to typed-IR parser tracked in #2974. /** * GSD Tools Tests - Phase * * Consolidated from 20 → 4 test files (issue #3740). This file covers the * CJS phase CLI layer (phase.cjs + gsd-tools.cjs): add, remove, complete, * list, insert, and all regression tests for bugs in that layer. */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('node:os'); const { execFileSync, spawnSync } = require('node:child_process'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); function normalizePhaseToken(token) { return String(token).replace(/\d+/g, (digits) => String(Number(digits))); } function phaseTokenFromDirName(name) { const match = name.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+[A-Z]?(?:\.\d+)*)/i); return match ? match[1] : null; } function writePassedVerificationForPhase(tmpDir, phase) { const phasesDir = path.join(tmpDir, '.planning', 'phases'); const wanted = normalizePhaseToken(phase); const phaseDirName = fs.readdirSync(phasesDir) .find((name) => normalizePhaseToken(phaseTokenFromDirName(name) || '') === wanted); assert.ok(phaseDirName, `expected phase directory for Phase ${phase}`); const phaseDir = path.join(phasesDir, phaseDirName); fs.writeFileSync( path.join(phaseDir, `${phase}-VERIFICATION.md`), ['---', 'status: passed', '---', '', '# Verification', ''].join('\n'), ); } function runVerifiedPhaseComplete(args, tmpDir, env) { const argv = Array.isArray(args) ? args : (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) .map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1')); const completeIdx = argv.findIndex((token, index) => token === 'complete' && argv[index - 1] === 'phase'); assert.notEqual(completeIdx, -1, `expected phase complete command, got ${argv.join(' ')}`); const phase = argv[completeIdx + 1]; assert.ok(phase, `expected phase number in command ${argv.join(' ')}`); writePassedVerificationForPhase(tmpDir, phase); return runGsdTools(args, tmpDir, env); } function writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus) { const planningDir = path.join(tmpDir, '.planning'); const phase1Dir = path.join(planningDir, 'phases', '01-foundation'); const phase2Dir = path.join(planningDir, 'phases', '02-api'); fs.mkdirSync(phase1Dir, { recursive: true }); fs.mkdirSync(phase2Dir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 1: Foundation', '- [ ] Phase 2: API', '', '### Phase 1: Foundation', '**Goal:** Setup', '**Plans:** 1 plans', '', '### Phase 2: API', '**Goal:** Build API', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/1 | Not started | - |', '| 02. API | 0/1 | Not started | - |', '', ].join('\n'), ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Current Plan:** 01-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working on phase 1', '', ].join('\n'), ); fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n'); if (verificationStatus !== null) { fs.writeFileSync( path.join(phase1Dir, '01-VERIFICATION.md'), [ '---', `status: ${verificationStatus}`, '---', '', '# Verification', '', ].join('\n'), ); } } describe('phases list command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('empty phases directory returns empty array', () => { const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.directories, [], 'directories should be empty'); assert.strictEqual(output.count, 0, 'count should be 0'); }); test('lists phase directories sorted numerically', () => { // Create out-of-order directories fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '10-final'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 3, 'should have 3 directories'); assert.deepStrictEqual( output.directories, ['01-foundation', '02-api', '10-final'], 'should be sorted numerically' ); }); test('handles decimal phases in sort order', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.1-hotfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.2-patch'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-ui'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.directories, ['02-api', '02.1-hotfix', '02.2-patch', '03-ui'], 'decimal phases should sort correctly between whole numbers' ); }); test('--type plans lists only PLAN.md files', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(phaseDir, 'RESEARCH.md'), '# Research'); const result = runGsdTools('phases list --type plans', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.files.sort(), ['01-01-PLAN.md', '01-02-PLAN.md'], 'should list only PLAN files' ); }); test('--type summaries lists only SUMMARY.md files', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1'); fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary 2'); const result = runGsdTools('phases list --type summaries', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.files.sort(), ['01-01-SUMMARY.md', '01-02-SUMMARY.md'], 'should list only SUMMARY files' ); }); test('--phase filters to specific phase directory', () => { const phase01 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); const phase02 = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phase01, { recursive: true }); fs.mkdirSync(phase02, { recursive: true }); fs.writeFileSync(path.join(phase01, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phase02, '02-01-PLAN.md'), '# Plan'); const result = runGsdTools('phases list --type plans --phase 01', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.files, ['01-01-PLAN.md'], 'should only list phase 01 plans'); assert.strictEqual(output.phase_dir, 'foundation', 'should report phase name without number prefix'); }); }); // ───────────────────────────────────────────────────────────────────────────── // roadmap get-phase command // ───────────────────────────────────────────────────────────────────────────── describe('phase next-decimal command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('returns X.1 when no decimal phases exist', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '07-next'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.next, '06.1', 'should return 06.1'); assert.deepStrictEqual(output.existing, [], 'no existing decimals'); }); test('increments from existing decimal phases', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-hotfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-patch'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.next, '06.3', 'should return 06.3'); assert.deepStrictEqual(output.existing, ['06.1', '06.2'], 'lists existing decimals'); }); test('handles gaps in decimal sequence', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-first'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-third'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Should take next after highest, not fill gap assert.strictEqual(output.next, '06.4', 'should return 06.4, not fill gap at 06.2'); }); test('handles single-digit phase input', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); const result = runGsdTools('phase next-decimal 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.next, '06.1', 'should normalize to 06.1'); assert.strictEqual(output.base_phase, '06', 'base phase should be padded'); }); test('returns error if base phase does not exist', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-start'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command should succeed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'base phase not found'); assert.strictEqual(output.next, '06.1', 'should still suggest 06.1'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase-plan-index command // ───────────────────────────────────────────────────────────────────────────── describe('phase-plan-index command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('empty phase directory returns empty plans array', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase, '03', 'phase number correct'); assert.deepStrictEqual(output.plans, [], 'plans should be empty'); assert.deepStrictEqual(output.waves, {}, 'waves should be empty'); assert.deepStrictEqual(output.incomplete, [], 'incomplete should be empty'); assert.strictEqual(output.has_checkpoints, false, 'no checkpoints'); assert.ok(output.warning === undefined, 'truly empty dir must not emit a warning'); }); // #2893 — when the planner produces filenames that don't match the canonical // `{padded_phase}-{NN}-PLAN.md` contract, the executor used to silently see // plan_count: 0 with no signal. Now the response must include a `warning` // field naming every offender, so the user gets an actionable error instead // of "execute-phase blocked, no clue why". test('non-canonical plan filenames surface a warning naming each offender (#2893)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // The reporter's exact symptom: planner wrote `{phase-id}-PLAN-{N}-{slug}.md`. fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n'); fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n'); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 0, 'non-canonical files are not silently accepted'); assert.ok(typeof output.warning === 'string', 'warning field must be present'); assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender'); assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender'); assert.ok( output.warning.includes('{padded_phase}-{NN}-PLAN.md'), 'warning cites the canonical pattern so user knows what to rename to', ); }); test('canonical plans suppress the warning even alongside derivative files (#2893)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // Canonical plan + the legitimate derivative artifacts the planner emits. fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n'); fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n'); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n'); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 1, 'canonical plan detected'); assert.ok( output.warning === undefined, `outline and pre-bounce files must not trigger the warning, got: ${output.warning}`, ); }); // #2893 parity — find-phase reads the same phase directory and applies the // same canonical filter, so it must emit the same warning shape. Without // these tests the two code paths could silently diverge. test('find-phase: non-canonical plan filenames surface the same warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n'); fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n'); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase directory found'); assert.deepStrictEqual(output.plans, [], 'non-canonical files are not silently accepted'); assert.ok(typeof output.warning === 'string', 'warning field must be present'); assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender'); assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender'); assert.ok( output.warning.includes('{padded_phase}-{NN}-PLAN.md'), 'warning cites the canonical pattern', ); }); test('find-phase: canonical plans + derivatives suppress the warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n'); fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n'); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n'); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.plans, ['03-01-PLAN.md'], 'canonical plan detected'); assert.ok( output.warning === undefined, `outline and pre-bounce files must not trigger the warning, got: ${output.warning}`, ); }); // #2893 parity — `phases list --type plans` aggregates across phase dirs // and prefixes each warning with `${dir}: ` so the user can locate the // offending phase. Test mirrors the find-phase pair but accounts for that // prefix in the assertion. test('phases list --type plans: non-canonical filenames surface a per-dir warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n'); fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n'); const result = runGsdTools('phases list --type plans --phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.files, [], 'non-canonical files are not silently accepted'); assert.ok(typeof output.warning === 'string', 'warning field must be present'); assert.ok(output.warning.includes('03-api:'), 'warning is prefixed with the offending phase dir'); assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender'); assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender'); assert.ok( output.warning.includes('{padded_phase}-{NN}-PLAN.md'), 'warning cites the canonical pattern', ); }); test('phases list --type plans: canonical plans suppress the warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n'); fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n'); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n'); const result = runGsdTools('phases list --type plans --phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.files, ['03-01-PLAN.md'], 'canonical plan detected'); assert.ok( output.warning === undefined, `outline and pre-bounce files must not trigger the warning, got: ${output.warning}`, ); }); test('extracts single plan with frontmatter', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '03-01-PLAN.md'), `--- wave: 1 autonomous: true objective: Set up database schema files-modified: [prisma/schema.prisma, src/lib/db.ts] --- ## Task 1: Create schema ## Task 2: Generate client ` ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 1, 'should have 1 plan'); assert.strictEqual(output.plans[0].id, '03-01', 'plan id correct'); assert.strictEqual(output.plans[0].wave, 1, 'wave extracted'); assert.strictEqual(output.plans[0].autonomous, true, 'autonomous extracted'); assert.strictEqual(output.plans[0].objective, 'Set up database schema', 'objective extracted'); assert.deepStrictEqual(output.plans[0].files_modified, ['prisma/schema.prisma', 'src/lib/db.ts'], 'files extracted'); assert.strictEqual(output.plans[0].task_count, 2, 'task count correct'); assert.strictEqual(output.plans[0].has_summary, false, 'no summary yet'); }); test('groups multiple plans by wave (DAG-bucketing: 03-03 depends on 03-01 and 03-02)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '03-01-PLAN.md'), [ '---', 'wave: 1', 'autonomous: true', 'objective: Database setup', 'depends_on: []', '---', '', '## Task 1: Schema', ].join('\n') ); fs.writeFileSync( path.join(phaseDir, '03-02-PLAN.md'), [ '---', 'wave: 1', 'autonomous: true', 'objective: Auth setup', 'depends_on: []', '---', '', '## Task 1: JWT', ].join('\n') ); fs.writeFileSync( path.join(phaseDir, '03-03-PLAN.md'), [ '---', 'wave: 2', 'autonomous: false', 'objective: API routes', 'depends_on:', ' - 03-01', ' - 03-02', '---', '', '## Task 1: Routes', ].join('\n') ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 3, 'should have 3 plans'); assert.deepStrictEqual(output.waves['1'], ['03-01', '03-02'], 'wave 1 has 2 plans'); assert.deepStrictEqual(output.waves['2'], ['03-03'], 'wave 2 has 1 plan'); // No mismatch warning: declared wave 2 matches topo level 2 assert.strictEqual(output.warnings, undefined, 'no warnings when declared wave matches DAG'); }); test('detects incomplete plans (no matching summary)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan with summary fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), `---\nwave: 1\n---\n## Task 1`); fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`); // Plan without summary fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), `---\nwave: 2\n---\n## Task 1`); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans[0].has_summary, true, 'first plan has summary'); assert.strictEqual(output.plans[1].has_summary, false, 'second plan has no summary'); assert.deepStrictEqual(output.incomplete, ['03-02'], 'incomplete list correct'); }); test('phase-plan-index matches descriptive plan with prefix summary (#3101)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1`); fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans[0].has_summary, true, 'descriptive plan should match prefix summary'); assert.deepStrictEqual(output.incomplete, [], 'plan should not be marked incomplete'); }); // #3266 CR — depends_on canonical-id mismatch: a plan named // '03-01-auth-hardening-PLAN.md' is stored with id '03-01-auth-hardening', // but a dependency declared as '03-01' was never resolving to it, silently // putting the dependent plan in the same wave as its prerequisite. test('depends_on short canonical prefix resolves against descriptive plan filename (#3266)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan 01: descriptive filename — id becomes '03-01-auth-hardening' fs.writeFileSync( path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1\n`, ); // Plan 02: depends on the canonical prefix '03-01' (not the full stem) fs.writeFileSync( path.join(phaseDir, '03-02-followup-PLAN.md'), `---\ndepends_on:\n - '03-01'\n---\n## Task 1\n`, ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; // Plan 01 must be in an earlier wave than plan 02 const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-02'))); assert.ok(wave01 !== undefined, 'plan 03-01-auth-hardening should appear in waves'); assert.ok(wave02 !== undefined, 'plan 03-02-followup should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `03-02 must be in a later wave than 03-01 (got wave01=${wave01}, wave02=${wave02})`, ); }); test('detects checkpoints (autonomous: false)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '03-01-PLAN.md'), `--- wave: 1 autonomous: false objective: Manual review needed --- ## Task 1: Review ` ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.has_checkpoints, true, 'should detect checkpoint'); assert.strictEqual(output.plans[0].autonomous, false, 'plan marked non-autonomous'); }); test('phase not found returns error', () => { const result = runGsdTools('phase-plan-index 99', tmpDir); assert.ok(result.success, `Command should succeed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.error, 'Phase not found', 'should report phase not found'); }); // #3785 — case-insensitive depends_on resolution test('#3785: depends_on reference with different case resolves to correct plan', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '20-case-insensitive'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: filename uses uppercase suffix — plan ID becomes '20-01-Auth' fs.writeFileSync( path.join(phaseDir, '20-01-Auth-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan A.\n`, ); // Plan B: depends_on uses lowercase — must still resolve to Plan A fs.writeFileSync( path.join(phaseDir, '20-02-PLAN.md'), `---\nwave: 2\nautonomous: true\ndepends_on:\n - 20-01-auth\n---\nPlan B.\n`, ); const result = runGsdTools('phase-plan-index 20', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-02'))); assert.ok(wave01 !== undefined, 'plan 20-01-Auth should appear in waves'); assert.ok(wave02 !== undefined, 'plan 20-02 should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `20-02 must be in a later wave than 20-01 (got wave01=${wave01}, wave02=${wave02}) — DAG edge dropped (case mismatch)`, ); // Lock canonical casing: plan ID must be preserved as-is from the filename, not lowercased. const planA = output.plans.find(p => p.id.startsWith('20-01')); assert.strictEqual(planA.id, '20-01-Auth', 'canonical casing must be preserved in plan ID'); // depends_on output must use canonical plan ID, not the user-typed casing. const planB = output.plans.find(p => p.id === '20-02'); assert.deepStrictEqual(planB.depends_on, ['20-01-Auth'], 'depends_on output must resolve to canonical ID casing'); // No unresolved-dep warning const warnings = output.warnings ?? []; assert.ok( !warnings.some(w => /unresolved/i.test(w)), `Unexpected unresolved-dep warning: ${JSON.stringify(warnings)}`, ); }); // #3785 adversarial: two plan IDs that are identical when case-folded must // fail fast with a clear error instead of silently routing edges to the wrong plan. // This test can only run on Linux where the filesystem is case-sensitive. // On macOS/Windows (case-insensitive FS), writing both files silently collapses // them to one file, so the collision scenario cannot be triggered via disk. test('#3785 adversarial: two plan IDs differing only by case produce a collision error', { skip: process.platform !== 'linux' ? 'case-insensitive filesystem — collision test requires Linux' : false, }, () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '21-collision'); fs.mkdirSync(phaseDir, { recursive: true }); // '21-01-auth-PLAN.md' → id '21-01-auth' // '21-01-Auth-PLAN.md' → id '21-01-Auth' // Both lowercase to '21-01-auth' — collision. fs.writeFileSync( path.join(phaseDir, '21-01-auth-PLAN.md'), `---\nautonomous: true\ndepends_on: []\n---\nlowercase.\n`, ); fs.writeFileSync( path.join(phaseDir, '21-01-Auth-PLAN.md'), `---\nautonomous: true\ndepends_on: []\n---\nuppercase.\n`, ); const result = runGsdTools('phase-plan-index 21', tmpDir); // The command must exit with an error (non-success) naming the collision. assert.ok(!result.success, 'phase-plan-index must fail when two plan IDs collide under case-folding'); assert.ok( /collision/i.test(result.error ?? result.output ?? ''), `Error output must mention 'collision', got: ${result.error ?? result.output}`, ); }); // #3785 — all-uppercase depends_on value resolves to an all-lowercase plan ID test('#3785: all-uppercase depends_on ref resolves to lowercase plan ID', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '22-uppercase-dep'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: all-lowercase plan ID fs.writeFileSync( path.join(phaseDir, '22-01-setup-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan A.\n`, ); // Plan B: depends_on uses ALL-UPPERCASE — must still route the DAG edge to Plan A fs.writeFileSync( path.join(phaseDir, '22-02-PLAN.md'), `---\nwave: 2\nautonomous: true\ndepends_on:\n - 22-01-SETUP\n---\nPlan B.\n`, ); const result = runGsdTools('phase-plan-index 22', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-02'))); assert.ok(wave01 !== undefined, 'plan 22-01-setup should appear in waves'); assert.ok(wave02 !== undefined, 'plan 22-02 should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `22-02 must be in a later wave than 22-01 — all-uppercase dep should route correctly (got wave01=${wave01}, wave02=${wave02})`, ); // depends_on output must use canonical plan ID (lowercase as-on-disk), not the uppercase ref const planB = output.plans.find(p => p.id === '22-02'); assert.deepStrictEqual(planB.depends_on, ['22-01-setup'], 'depends_on output must resolve to canonical lowercase ID'); }); // #3785 — external (cross-phase) depends_on reference is kept as-is in output // The Pass 3 mapping must return the original dep string when planMap has no entry for it. test('#3785: external cross-phase depends_on ref is preserved as-is in output', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '23-external-dep'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: references a plan in a different phase (01-some-other-phase) — planMap won't have it fs.writeFileSync( path.join(phaseDir, '23-01-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on:\n - 01-01-prereq\n---\nPlan A.\n`, ); const result = runGsdTools('phase-plan-index 23', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const planA = output.plans.find(p => p.id.startsWith('23-01')); assert.ok(planA !== undefined, 'plan 23-01 should appear in output'); // External dep must be preserved verbatim — not dropped, not resolved assert.deepStrictEqual(planA.depends_on, ['01-01-prereq'], 'external cross-phase dep must be kept as-is in output'); }); // #3785 — mixed-case canonical prefix in depends_on resolves via canonicalToId lookup // e.g. depends_on: '22-01-SETUP' where extractCanonicalPlanId gives '22-01' keyed lowercase test('#3785: mixed-case short canonical prefix in depends_on resolves via canonicalToId', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '24-canon-case'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: descriptive filename — id becomes '24-01-auth-hardening' fs.writeFileSync( path.join(phaseDir, '24-01-auth-hardening-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan A.\n`, ); // Plan B: depends_on uses an uppercase short prefix '24-01' — canonicalToId maps '24-01' → '24-01-auth-hardening' fs.writeFileSync( path.join(phaseDir, '24-02-followup-PLAN.md'), `---\nwave: 2\nautonomous: true\ndepends_on:\n - '24-01'\n---\nPlan B.\n`, ); const result = runGsdTools('phase-plan-index 24', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-02'))); assert.ok(wave01 !== undefined, '24-01-auth-hardening should appear in waves'); assert.ok(wave02 !== undefined, '24-02-followup should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `24-02 must be in a later wave than 24-01 via canonicalToId lookup (got wave01=${wave01}, wave02=${wave02})`, ); // depends_on output: '24-01' is the canonical prefix, not in planMap directly, so falls back to dep as-is const planB = output.plans.find(p => p.id === '24-02-followup'); assert.ok(planB !== undefined, '24-02-followup plan must be in output'); // The dep '24-01' is not a planMap key (full id is '24-01-auth-hardening'), so output keeps '24-01' assert.deepStrictEqual(planB.depends_on, ['24-01'], 'short canonical prefix dep falls through to as-is in Pass 3 output'); }); // #3785 — plans with no depends_on (empty array) still emit correct output without errors test('#3785: plans with undefined/empty depends_on emit empty array without errors', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '25-no-deps'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan with no depends_on key at all fs.writeFileSync( path.join(phaseDir, '25-01-PLAN.md'), `---\nwave: 1\nautonomous: true\n---\nPlan A, no deps.\n`, ); // Plan with explicit empty depends_on array fs.writeFileSync( path.join(phaseDir, '25-02-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan B, explicit empty deps.\n`, ); const result = runGsdTools('phase-plan-index 25', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const plan01 = output.plans.find(p => p.id === '25-01'); const plan02 = output.plans.find(p => p.id === '25-02'); assert.ok(plan01 !== undefined, 'plan 25-01 should appear in output'); assert.ok(plan02 !== undefined, 'plan 25-02 should appear in output'); assert.deepStrictEqual(plan01.depends_on, [], 'plan with no depends_on key must emit empty array'); assert.deepStrictEqual(plan02.depends_on, [], 'plan with explicit empty depends_on must emit empty array'); // Both independent plans land in the same wave assert.deepStrictEqual(output.waves['1'], ['25-01', '25-02'], 'both no-dep plans should be in wave 1'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase-plan-index — canonical XML format (template-aligned) // ───────────────────────────────────────────────────────────────────────────── describe('phase-plan-index canonical format', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('files_modified: underscore key is parsed correctly', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- wave: 1 autonomous: true files_modified: [src/App.tsx, src/index.ts] --- Build main application shell Purpose: Entry point Output: App component Task 1: Create App component src/App.tsx Create component npm run build Component renders ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.plans[0].files_modified, ['src/App.tsx', 'src/index.ts'], 'files_modified with underscore should be parsed' ); }); test('objective: extracted from XML tag, not frontmatter', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- wave: 1 autonomous: true files_modified: [] --- Build main application shell Purpose: Entry point for the SPA Output: App.tsx with routing Task 1: Scaffold src/App.tsx Create shell build passes App renders ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.plans[0].objective, 'Build main application shell', 'objective should come from XML tag first line' ); }); test('task_count: counts XML tags', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- wave: 1 autonomous: true files_modified: [] --- Create UI components Task 1: Header src/Header.tsx Create header build Header renders Task 2: Footer src/Footer.tsx Create footer build Footer renders UI components Visit localhost:3000 Type approved ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.plans[0].task_count, 3, 'should count all 3 XML tags' ); }); test('all three fields work together in canonical plan format', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- phase: 04-ui plan: 01 type: execute wave: 1 depends_on: [] files_modified: [src/components/Chat.tsx, src/app/api/chat/route.ts] autonomous: true requirements: [R1, R2] --- Implement complete Chat feature as vertical slice. Purpose: Self-contained chat that can run parallel to other features. Output: Chat component, API endpoints. @~/.claude/gsd-core/workflows/execute-plan.md @.planning/PROJECT.md @.planning/ROADMAP.md Task 1: Create Chat component src/components/Chat.tsx Build chat UI with message list and input npm run build Chat component renders messages Task 2: Create Chat API src/app/api/chat/route.ts GET /api/chat and POST /api/chat endpoints curl tests pass CRUD operations work - [ ] npm run build succeeds - [ ] API endpoints respond correctly ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const plan = output.plans[0]; assert.strictEqual(plan.objective, 'Implement complete Chat feature as vertical slice.', 'objective from XML tag'); assert.deepStrictEqual(plan.files_modified, ['src/components/Chat.tsx', 'src/app/api/chat/route.ts'], 'files_modified with underscore'); assert.strictEqual(plan.task_count, 2, 'task_count from XML tags'); }); }); // ───────────────────────────────────────────────────────────────────────────── // state-snapshot command // ───────────────────────────────────────────────────────────────────────────── describe('phase add command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('adds phase after highest existing', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API --- ` ); const result = runGsdTools('phase add User Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 3, 'should be phase 3'); assert.strictEqual(output.slug, 'user-dashboard'); // Verify directory created assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')), 'directory should be created' ); // Verify ROADMAP updated const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase'); assert.ok(roadmap.includes('**Depends on:** Phase 2'), 'should depend on previous'); }); test('handles empty roadmap', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n` ); const result = runGsdTools('phase add Initial Setup', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 1, 'should be phase 1'); }); test('phase add includes **Requirements**: TBD in new ROADMAP entry', () => { 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('phase add User Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('**Requirements**: TBD'), 'new phase entry should include Requirements TBD'); }); test('phase add ignores --raw instead of persisting it in the description', () => { 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(['phase', 'add', '--raw', 'User', 'Dashboard'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('### Phase 2: User Dashboard'), 'description should exclude --raw'); assert.ok(!roadmap.includes('--raw'), 'raw flag must not be persisted into ROADMAP.md'); }); test('phase add rejects unsupported flags and dangling --id', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const unsupported = runGsdTools(['phase', 'add', '--unknown', 'Dashboard'], tmpDir); assert.ok(!unsupported.success, 'unsupported flags should fail'); assert.match(unsupported.error, /phase add does not support --unknown/); const dangling = runGsdTools(['phase', 'add', 'Dashboard', '--id'], tmpDir); assert.ok(!dangling.success, 'dangling --id should fail'); assert.match(dangling.error, /--id requires a value/); }); test('skips 999.x backlog phases when calculating next phase number', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API ### Phase 3: UI **Goal:** Build UI ### Phase 999.1: Future Idea A **Goal:** Backlog item ### Phase 999.2: Future Idea B **Goal:** Backlog item --- ` ); const result = runGsdTools('phase add Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 4, 'should be phase 4, not 1000'); assert.strictEqual(output.slug, 'dashboard'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-dashboard')), 'directory should be 04-dashboard, not 1000-dashboard' ); }); test('CJS scanner [999, 1000] fixture: skips exactly 999 and returns 1001 (regression #3774)', () => { // Locks the BLOCKER fix in phase.cjs: guards at :610, :624, :688, :698 must // use === 999 (not >= 999). With >= 999, phase 1000 is excluded from the // max-scan and the result collapses back toward 1 instead of 1001. // // GSD_WORKSTREAM=ws1 forces the CJS fallback in phase-command-router.cjs. // When GSD_WORKSTREAM is set, planningDir resolves to // .planning/workstreams// — so ROADMAP.md and phases/ live there. const ws = 'ws1'; const planningBase = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(planningBase, 'phases'), { recursive: true }); fs.writeFileSync( path.join(planningBase, 'ROADMAP.md'), [ '# Roadmap', '', '## Current Milestone: v1.0', '', '### Phase 999: Backlog', '', '**Goal:** Backlog sentinel', '**Plans:** 0 plans', '', '### Phase 1000: First Four-Digit Phase', '', '**Goal:** First canonical phase above backlog sentinel', '**Requirements**: TBD', '**Plans:** 1 plans', '', 'Plans:', '- [x] 1000-01 (initial work)', '', '---', '*Last updated: 2026-05-21*', '', ].join('\n') ); // Create matching phase directories on disk (inside the workstream planning dir) fs.mkdirSync(path.join(planningBase, 'phases', '999-backlog'), { recursive: true }); fs.mkdirSync(path.join(planningBase, 'phases', '1000-first-four-digit'), { recursive: true }); const result = runGsdTools('phase add After One Thousand', tmpDir, { GSD_WORKSTREAM: ws }); assert.ok(result.success, `CJS phase add failed: ${result.error}`); const output = JSON.parse(result.output); // Must be 1001: skips 999 (backlog sentinel), keeps 1000, adds 1. // With the old >= 999 guard: phase 1000 is excluded → max stays 0 → result = 1. assert.strictEqual(output.phase_number, 1001, 'CJS scanner must return 1001, not 1 (regression #3774)'); assert.ok( fs.existsSync(path.join(planningBase, 'phases', '1001-after-one-thousand')), 'directory should be 1001-after-one-thousand' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase add — orphan directory collision prevention (#2026) // ───────────────────────────────────────────────────────────────────────────── describe('phase add — orphan directory collision prevention (#2026)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('orphan directory with higher number than ROADMAP pushes maxPhase up', () => { // Orphan directory 05-orphan exists on disk but is NOT in ROADMAP.md const orphanDir = path.join(tmpDir, '.planning', 'phases', '05-orphan'); fs.mkdirSync(orphanDir, { recursive: true }); fs.writeFileSync(path.join(orphanDir, 'SUMMARY.md'), 'existing work'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '## Milestone v1', '### Phase 1: First phase', '**Plans:** 0 plans', '---', ].join('\n') ); const result = runGsdTools('phase add dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // ROADMAP max is 1, but orphan 05-orphan means disk max is 5 → new phase = 6 assert.strictEqual(output.phase_number, 6, 'should be phase 6 (orphan 05 pushes max to 5)'); // The new directory must be 06-dashboard, not 02-dashboard assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard')), 'new phase directory must be 06-dashboard, not collide with orphan 05-orphan' ); // The orphan directory must be untouched assert.ok( fs.existsSync(path.join(orphanDir, 'SUMMARY.md')), 'orphan directory content must be preserved (not overwritten)' ); }); test('orphan directories with 999.x prefix are skipped when calculating disk max', () => { // 999.x backlog orphans must not inflate the next sequential phase number const backlogOrphan = path.join(tmpDir, '.planning', 'phases', '999-backlog-stuff'); fs.mkdirSync(backlogOrphan, { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '### Phase 1: Foundation', '**Plans:** 0 plans', '---', ].join('\n') ); const result = runGsdTools('phase add new-feature', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // ROADMAP max is 1, disk orphan is 999 (backlog) → should be ignored → new phase = 2 assert.strictEqual(output.phase_number, 2, 'backlog 999.x orphan must not inflate phase count'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-new-feature')), 'new phase directory should be 02-new-feature' ); }); test('project_code prefix in orphan directory name is stripped before comparing', () => { // Orphan directory has project_code prefix e.g. CK-05-orphan const orphanDir = path.join(tmpDir, '.planning', 'phases', 'CK-05-old-feature'); fs.mkdirSync(orphanDir, { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: 'CK' }) ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '### Phase 1: Foundation', '**Plans:** 0 plans', '---', ].join('\n') ); const result = runGsdTools('phase add new-feature', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // ROADMAP max is 1, disk has CK-05-old-feature → strip prefix → disk max is 5 → new phase = 6 assert.strictEqual(output.phase_number, 6, 'project_code prefix must be stripped before disk max calculation'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-06-new-feature')), 'new phase directory must be CK-06-new-feature' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase add with project_code prefix // ───────────────────────────────────────────────────────────────────────────── describe('phase add with project_code', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('prefixes phase directory with project_code', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: 'CK' }) ); 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('phase add User Dashboard', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 2, 'should be phase 2'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-user-dashboard')), 'directory should have CK- prefix' ); }); test('no prefix when project_code is null', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: null }) ); 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('phase add User Dashboard', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-user-dashboard')), 'directory should have no prefix' ); }); test('find-phase resolves prefixed directories', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 01', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'should find prefixed phase'); assert.strictEqual(output.phase_number, '01', 'should extract numeric phase number'); }); test('phases list sorts prefixed directories correctly', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-api'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-03-ui'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.directories, ['CK-01-foundation', 'CK-02-api', 'CK-03-ui'], 'prefixed phases should sort numerically' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase add-batch command (#2165) // ───────────────────────────────────────────────────────────────────────────── describe('phase add-batch command (#2165)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '---', '', ].join('\n') ); }); afterEach(() => { cleanup(tmpDir); }); test('adds multiple phases with sequential numbers in a single call', () => { // Use array form to avoid shell quoting issues with JSON args const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Alpha","Beta","Gamma"]'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 3, 'should report 3 phases added'); assert.strictEqual(output.phases[0].phase_number, 2); assert.strictEqual(output.phases[1].phase_number, 3); assert.strictEqual(output.phases[2].phase_number, 4); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-alpha')), '02-alpha dir must exist'); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-beta')), '03-beta dir must exist'); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-gamma')), '04-gamma dir must exist'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('### Phase 2: Alpha'), 'roadmap should include Phase 2'); assert.ok(roadmap.includes('### Phase 3: Beta'), 'roadmap should include Phase 3'); assert.ok(roadmap.includes('### Phase 4: Gamma'), 'roadmap should include Phase 4'); }); test('no duplicate phase numbers when multiple add-batch calls are made sequentially', () => { // Regression for #2165: parallel `phase add` invocations produced duplicates // because each read disk state before any write landed. add-batch serializes // the entire batch under a single lock so the next call sees the updated state. const r1 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-One-A","Wave-One-B"]'], tmpDir); assert.ok(r1.success, `First batch failed: ${r1.error}`); const r2 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-Two-A","Wave-Two-B"]'], tmpDir); assert.ok(r2.success, `Second batch failed: ${r2.error}`); const out1 = JSON.parse(r1.output); const out2 = JSON.parse(r2.output); const allNums = [...out1.phases, ...out2.phases].map(p => p.phase_number); const unique = new Set(allNums); assert.strictEqual(unique.size, allNums.length, `Duplicate phase numbers detected: ${allNums}`); // Directories must all exist and be unique const dirs = fs.readdirSync(path.join(tmpDir, '.planning', 'phases')); assert.strictEqual(dirs.length, 4, `Expected 4 phase dirs, got: ${dirs}`); }); test('each phase directory contains a .gitkeep file', () => { const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Setup","Build"]'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-setup', '.gitkeep')), '.gitkeep must exist in 02-setup' ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-build', '.gitkeep')), '.gitkeep must exist in 03-build' ); }); test('returns error for empty descriptions array', () => { const result = runGsdTools(['phase', 'add-batch', '--descriptions', '[]'], tmpDir); assert.ok(!result.success, 'should fail on empty array'); }); test('returns error when --descriptions JSON is not an array', () => { const result = runGsdTools(['phase', 'add-batch', '--descriptions', '{"one":"Alpha"}'], tmpDir); assert.ok(!result.success, 'should fail on non-array JSON'); assert.match(result.error, /--descriptions must be a JSON array/); }); test('returns error when --descriptions is missing its JSON value', () => { const missing = runGsdTools(['phase', 'add-batch', '--descriptions'], tmpDir); assert.ok(!missing.success, 'should fail on dangling --descriptions'); assert.match(missing.error, /--descriptions must be a JSON array/); const flagValue = runGsdTools(['phase', 'add-batch', '--descriptions', '--raw'], tmpDir); assert.ok(!flagValue.success, 'should fail when --descriptions value is another flag'); assert.match(flagValue.error, /--descriptions must be a JSON array/); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase insert command // ───────────────────────────────────────────────────────────────────────────── describe('phase insert command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('inserts decimal phase after target', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, '01.1', 'should be 01.1'); assert.strictEqual(output.after_phase, '1'); // Verify directory assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01.1-fix-critical-bug')), 'decimal phase directory should be created' ); // Verify ROADMAP const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('Phase 01.1: Fix Critical Bug (INSERTED)'), 'roadmap should include inserted phase'); }); test('increments decimal when siblings exist', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01.1-hotfix'), { recursive: true }); const result = runGsdTools('phase insert 1 Another Fix', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, '01.2', 'should be 01.2'); }); test('rejects missing phase', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: Test\n**Goal:** Test\n` ); const result = runGsdTools('phase insert 99 Fix Something', tmpDir); assert.ok(!result.success, 'should fail for missing phase'); assert.ok(result.error.includes('not found'), 'error mentions not found'); }); test('handles padding mismatch between input and roadmap', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ## Phase 09.05: Existing Decimal Phase **Goal:** Test padding ## Phase 09.1: Next Phase **Goal:** Test ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '09.05-existing'), { recursive: true }); // Pass unpadded "9.05" but roadmap has "09.05" const result = runGsdTools('phase insert 9.05 Padding Test', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.after_phase, '9.05'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('(INSERTED)'), 'roadmap should include inserted phase'); }); test('phase insert includes **Requirements**: TBD in new ROADMAP entry', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('**Requirements**: TBD'), 'inserted phase entry should include Requirements TBD'); }); test('reports actionable error for summary-only placeholder phase without detail section (#3098)', () => { // #3098: a hybrid ROADMAP that has heading-style phases for some phases // but only a bullet summary entry for phase 5 (the detail section is // missing). Insert must fail with "missing a detail section" rather than // silently inserting in bullet-style — because the surrounding ROADMAP // uses headings, so the absent `### Phase 5:` is a genuine omission. // (Compare with the #3815 case below: a purely bullet-style ROADMAP that // has NO heading-style phases at all is valid and insert should succeed.) fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 4: Foundation\n**Goal:** Setup\n\n- [ ] **Phase 5: Placeholder**\n` ); const result = runGsdTools('phase insert 5 Hotfix', tmpDir); assert.ok(!result.success, 'should fail when phase is summary-only placeholder in a heading-style ROADMAP'); assert.ok(result.error.includes('missing a detail section')); }); test('phase insert rejects unsupported --dry-run flag explicitly (#3098)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n` ); const result = runGsdTools('phase insert 1 Hotfix --dry-run', tmpDir); assert.ok(!result.success, 'phase insert should reject unsupported --dry-run'); assert.ok(result.error.includes('does not support --dry-run')); }); test('handles #### heading depth from multi-milestone roadmaps', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### v1.1 Milestone #### Phase 5: Feature Work **Goal:** Build features #### Phase 6: Polish **Goal:** Polish ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-feature-work'), { recursive: true }); const result = runGsdTools('phase insert 5 Hotfix', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, '05.1'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('Phase 05.1: Hotfix (INSERTED)'), 'roadmap should include inserted phase'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase remove command // ───────────────────────────────────────────────────────────────────────────── describe('phase remove command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('removes phase directory and renumbers subsequent', () => { // Setup 3 phases fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup **Depends on:** Nothing ### Phase 2: Auth **Goal:** Authentication **Depends on:** Phase 1 ### Phase 3: Features **Goal:** Core features **Depends on:** Phase 2 ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const p2 = path.join(tmpDir, '.planning', 'phases', '02-auth'); fs.mkdirSync(p2, { recursive: true }); fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); const p3 = path.join(tmpDir, '.planning', 'phases', '03-features'); fs.mkdirSync(p3, { recursive: true }); fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p3, '03-02-PLAN.md'), '# Plan 2'); // Remove phase 2 const result = runGsdTools('phase remove 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.removed, '2'); assert.strictEqual(output.directory_deleted, '02-auth'); // Phase 3 should be renumbered to 02 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features')), 'phase 3 should be renumbered to 02-features' ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-features')), 'old 03-features should not exist' ); // Files inside should be renamed assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-01-PLAN.md')), 'plan file should be renumbered to 02-01' ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-02-PLAN.md')), 'plan 2 should be renumbered to 02-02' ); // ROADMAP should be updated const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('Phase 2: Auth'), 'removed phase should not be in roadmap'); assert.ok(roadmap.includes('Phase 2: Features'), 'phase 3 should be renumbered to 2'); }); test('rejects removal of phase with summaries unless --force', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: Test\n**Goal:** Test\n` ); // Should fail without --force const result = runGsdTools('phase remove 1', tmpDir); assert.ok(!result.success, 'should fail without --force'); assert.ok(result.error.includes('executed plan'), 'error mentions executed plans'); // Should succeed with --force const forceResult = runGsdTools('phase remove 1 --force', tmpDir); assert.ok(forceResult.success, `Force remove failed: ${forceResult.error}`); }); test('bug-3409: supports --force before phase id', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); const result = runGsdTools('phase remove --force 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.removed, '2'); assert.strictEqual(output.directory_deleted, '02-b'); assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-b'))); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented after real removal'); }); test('removes decimal phase and renumbers siblings', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 6: Main\n**Goal:** Main\n### Phase 6.1: Fix A\n**Goal:** Fix A\n### Phase 6.2: Fix B\n**Goal:** Fix B\n### Phase 6.3: Fix C\n**Goal:** Fix C\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-main'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-fix-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-b'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c'), { recursive: true }); const result = runGsdTools('phase remove 6.2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // 06.3 should become 06.2 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-c')), '06.3 should be renumbered to 06.2' ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c')), 'old 06.3 should not exist' ); }); test('updates STATE.md phase count', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); runGsdTools('phase remove 2', tmpDir); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented'); }); test('bug-2434: integer phase remove does not rename 999.x backlog directory', () => { // Setup: an active integer phase 4 and a backlog phase 999.1 fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: Auth **Goal:** Authentication ### Phase 3: Features **Goal:** Core features ### Phase 4: Extras **Goal:** Extra stuff ### Phase 999.1: Backlog item **Goal:** Parked backlog task ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true }); const result = runGsdTools('phase remove 4', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // Backlog directory must remain at 999.1, not be decremented to 998.1 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')), 'backlog directory 999.1-backlog-item must not be renamed' ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '998.1-backlog-item')), 'backlog directory must not be incorrectly renamed to 998.1' ); }); test('bug-16: integer phase remove renumbers canonical phases above 999 while preserving 999.x backlog', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1199: Baseline **Goal:** Before removal Plans: - [x] 1199-01-PLAN.md ### Phase 1200: Remove Me **Goal:** Target phase Plans: - [ ] 1200-01-PLAN.md ### Phase 1201: Follow Up A **Goal:** First phase after target **Depends on:** Phase 1200 Plans: - [ ] 1201-01-PLAN.md ### Phase 1202: Follow Up B **Goal:** Second phase after target **Depends on:** Phase 1201 Plans: - [ ] 1202-01-PLAN.md ### Phase 999.1: Backlog Item **Goal:** Parked backlog item ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1199-baseline'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1200-remove-me'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true }); const result = runGsdTools('phase remove 1200', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // On-disk phase directories should be decremented by one above removedInt. assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1200-follow-up-a')), '1201-follow-up-a should be renamed to 1200-follow-up-a', ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-b')), '1202-follow-up-b should be renamed to 1201-follow-up-b', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a')), 'old 1201-follow-up-a directory should not remain', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b')), 'old 1202-follow-up-b directory should not remain', ); // Backlog 999.x must remain untouched. assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')), 'backlog directory 999.1-backlog-item must not be renamed', ); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('### Phase 1200: Remove Me'), 'removed phase 1200 section must be gone'); assert.ok(roadmap.includes('### Phase 1200: Follow Up A'), 'phase 1201 should be renumbered to 1200'); assert.ok(roadmap.includes('### Phase 1201: Follow Up B'), 'phase 1202 should be renumbered to 1201'); assert.ok(!roadmap.includes('### Phase 1202: Follow Up B'), 'old phase 1202 heading must not remain'); assert.ok(roadmap.includes('**Depends on:** Phase 1200'), 'depends-on reference above removed phase should be decremented'); assert.ok(roadmap.includes('### Phase 999.1: Backlog Item'), 'backlog phase 999.1 heading must not be renumbered'); }); test('bug-2435: integer phase remove does not corrupt YYYY-MM-DD dates in ROADMAP.md', () => { // Setup: removing phase 4 from a roadmap containing 2026-04-14 date strings fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup **Completed:** 2026-01-15 ### Phase 2: Auth **Goal:** Authentication **Completed:** 2026-02-20 ### Phase 3: Features **Goal:** Core features **Completed:** 2026-04-14 ### Phase 4: Extras **Goal:** Extra stuff ### Phase 5: Final **Goal:** Final phase ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true }); const result = runGsdTools('phase remove 4', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // Dates must be preserved exactly assert.ok(roadmap.includes('2026-01-15'), 'date 2026-01-15 must not be corrupted'); assert.ok(roadmap.includes('2026-02-20'), 'date 2026-02-20 must not be corrupted'); assert.ok(roadmap.includes('2026-04-14'), 'date 2026-04-14 must not be corrupted'); // Phase 5 should be renumbered to 4 assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4'); }); test('bug-2435: integer phase remove does not corrupt date whose month matches removed phase number', () => { // Setup: removing phase 4 from a roadmap containing 2026-05-14 // When renumbering phase 5→4, the regex must not replace "05-14" in the date "2026-05-14" fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup **Completed:** 2026-01-15 ### Phase 2: Auth **Goal:** Authentication **Completed:** 2026-02-20 ### Phase 3: Features **Goal:** Core features **Completed:** 2026-03-10 ### Phase 4: Extras **Goal:** Extra stuff ### Phase 5: Final **Goal:** Final phase **Due:** 2026-05-14 ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true }); const result = runGsdTools('phase remove 4', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // Date "2026-05-14" must not be corrupted to "2026-04-14" when phase 5 is renumbered to 4 assert.ok(roadmap.includes('2026-05-14'), 'date 2026-05-14 must not be corrupted when renumbering phase 5→4'); assert.ok(!roadmap.includes('2026-04-14'), 'date must not be incorrectly mutated to 2026-04-14'); // Phase 5 should be renumbered to 4 assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4'); }); test('bug-3355: integer phase remove renumbers roadmap once without collapsing later phases', () => { const lines = ['# Roadmap', '', '## Progress', '', '| Phase | Plans | Status | Notes |', '|---|---:|---|---|']; for (let n = 26; n <= 35; n++) { lines.push(`| ${n}. Phase ${n} | 0/1 | Planned | - |`); } lines.push(''); for (let n = 26; n <= 35; n++) { lines.push(`### Phase ${n}: Phase ${n}`); lines.push(`#### Phase ${n}.1: Phase ${n}.1 follow-up`); lines.push(`**Goal:** Build phase ${n}`); lines.push(n % 2 === 0 ? `**Depends on**: Phase ${n - 1}` : `**Depends on:** Phase ${n - 1}`); lines.push(`Plans: ${String(n).padStart(2, '0')}-01-PLAN.md`); lines.push(''); const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n}`); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, `${String(n).padStart(2, '0')}-01-PLAN.md`), '# Plan'); } fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), lines.join('\n')); const result = runGsdTools('phase remove 27 --force', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.equal((roadmap.match(/\|\s*27\.\s/g) || []).length, 1, 'progress row 27 appears once'); assert.equal((roadmap.match(/\|\s*28\.\s/g) || []).length, 1, 'progress row 28 appears once'); assert.equal((roadmap.match(/\|\s*34\.\s/g) || []).length, 1, 'progress row 34 appears once'); assert.equal((roadmap.match(/\|\s*35\.\s/g) || []).length, 0, 'old progress row 35 removed by renumber'); assert.equal((roadmap.match(/^### Phase 27:/gm) || []).length, 1, 'heading 27 appears once'); assert.equal((roadmap.match(/^### Phase 34:/gm) || []).length, 1, 'heading 34 appears once'); assert.equal((roadmap.match(/^### Phase 35:/gm) || []).length, 0, 'old heading 35 removed by renumber'); assert.equal((roadmap.match(/^#### Phase 27\.1:/gm) || []).length, 1, 'decimal heading 27.1 appears once'); assert.equal((roadmap.match(/^#### Phase 34\.1:/gm) || []).length, 1, 'decimal heading 34.1 appears once'); assert.equal((roadmap.match(/^#### Phase 35\.1:/gm) || []).length, 0, 'old decimal heading 35.1 removed by renumber'); assert.equal((roadmap.match(/\*\*Depends on\*\*:\s*Phase\s+28\b/g) || []).length, 1, 'bold depends-on with outside colon is decremented'); assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+29\b/g) || []).length, 1, 'legacy bold depends-on with inside colon is decremented'); assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+35\b/g) || []).length, 0, 'old depends-on 35 removed by renumber'); assert.equal((roadmap.match(/\b27-01-PLAN\.md\b/g) || []).length, 1, 'plan id 27-01 appears once'); assert.equal((roadmap.match(/\b34-01-PLAN\.md\b/g) || []).length, 1, 'plan id 34-01 appears once'); assert.equal((roadmap.match(/\b35-01-PLAN\.md\b/g) || []).length, 0, 'old plan id 35-01 removed by renumber'); for (let n = 27; n <= 34; n++) { assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n + 1}`)), `phase directory ${n} should preserve original phase slug ${n + 1}`, ); } }); }); // ───────────────────────────────────────────────────────────────────────────── // phase complete command // ───────────────────────────────────────────────────────────────────────────── describe('phase complete canonical verification gate (#1522)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); for (const [name, verificationStatus, expectedMessage] of [ ['missing verification report', null, /No verification report found/i], ['unknown verification status', 'unexpected_value', /Unexpected verification status/i], ['human-needed verification status', 'human_needed', /Human verification required/i], ['gap-bearing verification status', 'gaps_found', /Gaps found/i], ]) { test(`blocks ${name} before mutating ROADMAP or STATE`, () => { writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8'); const beforeState = fs.readFileSync(statePath, 'utf-8'); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'phase complete must fail when verification has not passed'); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_verification_incomplete'); assert.match(errorPayload.message, expectedMessage); assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap); assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState); }); } test('allows passed verification to complete and advance the phase', () => { writePhaseCompleteVerificationGateFixture(tmpDir, 'passed'); const result = runGsdTools(['phase', 'complete', '1'], tmpDir); assert.equal(result.success, true, `phase complete failed: ${result.error}`); const output = JSON.parse(result.output); assert.equal(output.completed_phase, '1'); assert.equal(output.next_phase, '02'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.match(roadmap, /- \[x\] Phase 1: Foundation/); assert.match(state, /\*\*Current Phase:\*\* 02/); }); test('blocks stale passed verification when summaries changed later', () => { writePhaseCompleteVerificationGateFixture(tmpDir, 'passed'); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-01-SUMMARY.md'); const verificationPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-VERIFICATION.md'); const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8'); const beforeState = fs.readFileSync(statePath, 'utf-8'); const older = new Date('2025-01-01T00:00:00.000Z'); const newer = new Date('2025-01-01T00:01:00.000Z'); fs.utimesSync(verificationPath, older, older); fs.utimesSync(summaryPath, newer, newer); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'phase complete must fail when verification is stale'); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_verification_incomplete'); assert.match(errorPayload.message, /stale/i); assert.match(errorPayload.message, /\/gsd:verify-work 0?1/); assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap); assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState); }); }); describe('phase complete command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('marks phase complete and transitions to next', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation - [ ] Phase 2: API ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ### Phase 2: API **Goal:** Build API ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.completed_phase, '1'); assert.strictEqual(output.plans_executed, '1/1'); assert.strictEqual(output.next_phase, '02'); assert.strictEqual(output.is_last_phase, false); // Verify STATE.md updated const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('**Current Phase:** 02'), 'should advance to phase 02'); assert.ok(state.includes('**Status:** Ready to plan'), 'status should be ready to plan'); assert.ok(state.includes('**Current Plan:** Not started'), 'plan should be reset'); // Verify ROADMAP checkbox const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('[x]'), 'phase should be checked off'); assert.ok(roadmap.includes('completed'), 'completion date should be added'); }); test('detects last phase in milestone', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: Only Phase\n**Goal:** Everything\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-only-phase'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, true, 'should detect last phase'); assert.strictEqual(output.next_phase, null, 'no next phase'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('Milestone complete'), 'status should be milestone complete'); }); test('updates REQUIREMENTS.md traceability when phase completes', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** AUTH-01, AUTH-02 **Plans:** 1 plans ### Phase 2: API **Goal:** Build API **Requirements:** API-01 ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements ### Authentication - [ ] **AUTH-01**: User can sign up with email - [ ] **AUTH-02**: User can log in - [ ] **AUTH-03**: User can reset password ### API - [ ] **API-01**: REST endpoints ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | Pending | | AUTH-02 | Phase 1 | Pending | | AUTH-03 | Phase 2 | Pending | | API-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); // Checkboxes updated for phase 1 requirements assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked'); assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked'); // Other requirements unchanged assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked'); assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked'); // Traceability table updated assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete'); assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete'); assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending'); assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending'); }); test('handles requirements with bracket format [REQ-01, REQ-02]', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** [AUTH-01, AUTH-02] **Plans:** 1 plans ### Phase 2: API **Goal:** Build API **Requirements:** [API-01] ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements ### Authentication - [ ] **AUTH-01**: User can sign up with email - [ ] **AUTH-02**: User can log in - [ ] **AUTH-03**: User can reset password ### API - [ ] **API-01**: REST endpoints ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | Pending | | AUTH-02 | Phase 1 | Pending | | AUTH-03 | Phase 2 | Pending | | API-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); // Checkboxes updated for phase 1 requirements (brackets stripped) assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked'); assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked'); // Other requirements unchanged assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked'); assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked'); // Traceability table updated assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete'); assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete'); assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending'); assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending'); }); test('handles phase with no requirements mapping', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Setup ### Phase 1: Setup **Goal:** Project setup (no requirements) **Plans:** 1 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **REQ-01**: Some requirement ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | REQ-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // REQUIREMENTS.md should be unchanged const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [ ] **REQ-01**'), 'REQ-01 should remain unchecked'); assert.ok(req.includes('| REQ-01 | Phase 2 | Pending |'), 'REQ-01 should remain Pending'); }); test('handles missing REQUIREMENTS.md gracefully', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation **Requirements:** REQ-01 ### Phase 1: Foundation **Goal:** Setup ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`); }); test('returns requirements_updated field in result', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** AUTH-01 **Plans:** 1 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **AUTH-01**: User can sign up ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.strictEqual(parsed.requirements_updated, true, 'requirements_updated should be true'); }); test('handles In Progress status in traceability table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** AUTH-01, AUTH-02 **Plans:** 1 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **AUTH-01**: User can sign up - [ ] **AUTH-02**: User can log in ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | In Progress | | AUTH-02 | Phase 1 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'In Progress should become Complete'); assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'Pending should become Complete'); }); test('scoped regex does not cross phase boundaries', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Setup - [ ] Phase 2: Auth ### Phase 1: Setup **Goal:** Project setup **Plans:** 1 plans ### Phase 2: Auth **Goal:** User authentication **Requirements:** AUTH-01 **Plans:** 0 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **AUTH-01**: User can sign up ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Setup\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // Phase 1 has no Requirements field, so Phase 2's AUTH-01 should NOT be updated const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [ ] **AUTH-01**'), 'AUTH-01 should remain unchecked (belongs to Phase 2)'); assert.ok(req.includes('| AUTH-01 | Phase 2 | Pending |'), 'AUTH-01 should remain Pending (belongs to Phase 2)'); }); test('handles multi-level decimal phase without regex crash', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [x] Phase 3: Lorem - [x] Phase 3.2: Ipsum - [ ] Phase 3.2.1: Dolor Sit - [ ] Phase 4: Amet ### Phase 3: Lorem **Goal:** Setup **Plans:** 1/1 plans complete **Requirements:** LOR-01 ### Phase 3.2: Ipsum **Goal:** Build **Plans:** 1/1 plans complete **Requirements:** IPS-01 ### Phase 03.2.1: Dolor Sit Polish (INSERTED) **Goal:** Polish **Plans:** 1/1 plans complete ### Phase 4: Amet **Goal:** Deliver **Requirements:** AMT-01: Filter items by category with AND logic (items matching ALL selected categories) ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements - [ ] **LOR-01**: Lorem database schema - [ ] **IPS-01**: Ipsum rendering engine - [ ] **AMT-01**: Filter items by category ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State **Current Phase:** 03.2.1 **Current Phase Name:** Dolor Sit Polish **Status:** Execution complete **Current Plan:** 03.2.1-01 **Last Activity:** 2025-01-01 **Last Activity Description:** Working ` ); const p32 = path.join(tmpDir, '.planning', 'phases', '03.2-ipsum'); const p321 = path.join(tmpDir, '.planning', 'phases', '03.2.1-dolor-sit'); const p4 = path.join(tmpDir, '.planning', 'phases', '04-amet'); fs.mkdirSync(p32, { recursive: true }); fs.mkdirSync(p321, { recursive: true }); fs.mkdirSync(p4, { recursive: true }); fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 03.2.1', tmpDir); assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [ ] **AMT-01**'), 'AMT-01 should remain unchanged'); }); test('preserves Milestone column in 5-column progress table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Foundation | v1.0 | 0/1 | Planned | | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); assert.strictEqual(cells[1], 'v1.0', 'Milestone column should be preserved'); assert.ok(cells[3].includes('Complete'), 'Status column should be Complete'); }); test('updates STATE.md with plain format fields (no bold)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: Only\n**Goal:** Test\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\nPhase: 1 of 1 (Only)\nStatus: In progress\nPlan: 01-01\nLast Activity: 2025-01-01\nLast Activity Description: Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-only'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('Milestone complete'), 'plain Status field should be updated'); assert.ok(state.includes('Not started'), 'plain Plan field should be updated'); // Verify compound format preserved assert.ok(state.match(/Phase:.*of\s+1/), 'should preserve "of N" in compound Phase format'); }); test('updates Plans Complete column in 4-column progress table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation - [ ] Phase 2: API ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ### Phase 2: API **Goal:** Build API ## Progress | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Foundation | 0/1 | Not started | - | | 2. API | 0/1 | Not started | - | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 4, 'should have 4 columns'); assert.strictEqual(cells[1], '1/1', 'Plans Complete column should be updated to 1/1'); assert.ok(cells[2].includes('Complete'), 'Status column should be Complete'); }); test('updates Plans Complete column in 5-column progress table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Foundation | v1.0 | 0/1 | Planned | | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); assert.strictEqual(cells[2], '1/1', 'Plans Complete column should be updated to 1/1'); assert.ok(cells[3].includes('Complete'), 'Status column should be Complete'); }); test('marks plan-level checkboxes on phase complete', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md \u2014 Schema migration - [ ] 01-02-PLAN.md \u2014 Auth setup ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 checkbox should be checked'); assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 checkbox should be checked'); assert.ok(!roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should not remain unchecked'); assert.ok(!roadmap.includes('[ ] 01-02-PLAN.md'), 'plan 01-02 should not remain unchecked'); }); test('marks bold-wrapped plan-level checkboxes on phase complete', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 2 plans Plans: - [ ] **01-01**: Schema migration - [ ] **01-02**: Auth setup ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('[x] **01-01**'), 'bold plan 01-01 checkbox should be checked'); assert.ok(roadmap.includes('[x] **01-02**'), 'bold plan 01-02 checkbox should be checked'); assert.ok(!roadmap.includes('[ ] **01-01**'), 'bold plan 01-01 should not remain unchecked'); assert.ok(!roadmap.includes('[ ] **01-02**'), 'bold plan 01-02 should not remain unchecked'); }); }); // ───────────────────────────────────────────────────────────────────────────── // comparePhaseNum and normalizePhaseName (imported directly) // ───────────────────────────────────────────────────────────────────────────── const { comparePhaseNum, normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs'); describe('comparePhaseNum', () => { test('sorts integer phases numerically', () => { assert.ok(comparePhaseNum('2', '10') < 0); assert.ok(comparePhaseNum('10', '2') > 0); assert.strictEqual(comparePhaseNum('5', '5'), 0); }); test('sorts decimal phases correctly', () => { assert.ok(comparePhaseNum('12', '12.1') < 0); assert.ok(comparePhaseNum('12.1', '12.2') < 0); assert.ok(comparePhaseNum('12.2', '13') < 0); }); test('sorts letter-suffix phases correctly', () => { assert.ok(comparePhaseNum('12', '12A') < 0); assert.ok(comparePhaseNum('12A', '12B') < 0); assert.ok(comparePhaseNum('12B', '13') < 0); }); test('sorts hybrid phases correctly', () => { assert.ok(comparePhaseNum('12A', '12A.1') < 0); assert.ok(comparePhaseNum('12A.1', '12A.2') < 0); assert.ok(comparePhaseNum('12A.2', '12B') < 0); }); test('handles full sort order', () => { const phases = ['13', '12B', '12A.2', '12', '12.1', '12A', '12A.1', '12.2']; phases.sort(comparePhaseNum); assert.deepStrictEqual(phases, ['12', '12.1', '12.2', '12A', '12A.1', '12A.2', '12B', '13']); }); test('handles directory names with slugs', () => { const dirs = ['13-deploy', '12B-hotfix', '12A.1-bugfix', '12-foundation', '12.1-inserted', '12A-split']; dirs.sort(comparePhaseNum); assert.deepStrictEqual(dirs, [ '12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy' ]); }); test('case insensitive letter matching', () => { assert.ok(comparePhaseNum('12a', '12B') < 0); assert.ok(comparePhaseNum('12A', '12b') < 0); assert.strictEqual(comparePhaseNum('12a', '12A'), 0); }); test('sorts multi-level decimal phases correctly', () => { assert.ok(comparePhaseNum('3.2', '3.2.1') < 0); assert.ok(comparePhaseNum('3.2.1', '3.2.2') < 0); assert.ok(comparePhaseNum('3.2.1', '3.3') < 0); assert.ok(comparePhaseNum('3.2.1', '4') < 0); assert.strictEqual(comparePhaseNum('3.2.1', '3.2.1'), 0); }); test('falls back to localeCompare for non-phase strings', () => { const result = comparePhaseNum('abc', 'def'); assert.strictEqual(typeof result, 'number'); }); }); describe('normalizePhaseName', () => { test('pads single-digit integers', () => { assert.strictEqual(normalizePhaseName('3'), '03'); assert.strictEqual(normalizePhaseName('12'), '12'); }); test('handles decimal phases', () => { assert.strictEqual(normalizePhaseName('3.1'), '03.1'); assert.strictEqual(normalizePhaseName('12.2'), '12.2'); }); test('handles letter-suffix phases', () => { assert.strictEqual(normalizePhaseName('3A'), '03A'); assert.strictEqual(normalizePhaseName('12B'), '12B'); }); test('handles hybrid phases', () => { assert.strictEqual(normalizePhaseName('3A.1'), '03A.1'); assert.strictEqual(normalizePhaseName('12A.2'), '12A.2'); }); test('preserves letter case', () => { assert.strictEqual(normalizePhaseName('3a'), '03a'); assert.strictEqual(normalizePhaseName('12b.1'), '12b.1'); }); test('handles multi-level decimal phases', () => { assert.strictEqual(normalizePhaseName('3.2.1'), '03.2.1'); assert.strictEqual(normalizePhaseName('12.3.4'), '12.3.4'); }); test('returns non-matching input unchanged', () => { assert.strictEqual(normalizePhaseName('abc'), 'abc'); }); }); describe('letter-suffix phase sorting', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('lists letter-suffix phases in correct order', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12.1-inserted'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A-split'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A.1-bugfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12B-hotfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '13-deploy'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.directories, ['12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'], 'letter-suffix phases should sort correctly' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // milestone-scoped next-phase in phase complete // ───────────────────────────────────────────────────────────────────────────── describe('phase complete milestone-scoped next-phase', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('finds next phase within milestone, ignoring prior milestone dirs', () => { // ROADMAP lists phases 5-6 (current milestone v2.0) fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '## Roadmap v2.0: Release', '', '- [ ] Phase 5: Auth', '- [ ] Phase 6: Dashboard', '', '### Phase 5: Auth', '**Goal:** Add authentication', '**Plans:** 1 plans', '', '### Phase 6: Dashboard', '**Goal:** Build dashboard', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n' ); // Disk has dirs 01-06 (01-04 completed from prior milestone) for (let i = 1; i <= 4; i++) { const padded = String(i).padStart(2, '0'); const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-old-phase`); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan'); fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary'); } // Phase 5 — completing this one const p5 = path.join(tmpDir, '.planning', 'phases', '05-auth'); fs.mkdirSync(p5, { recursive: true }); fs.writeFileSync(path.join(p5, '05-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p5, '05-01-SUMMARY.md'), '# Summary'); // Phase 6 — next phase in milestone fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, false, 'should NOT be last phase — phase 6 is in milestone'); assert.strictEqual(output.next_phase, '06', 'next phase should be 06'); }); test('detects last phase when only milestone phases are considered', () => { // ROADMAP lists only phase 5 (current milestone) fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '## Roadmap v2.0: Release', '', '### Phase 5: Auth', '**Goal:** Add authentication', '**Plans:** 1 plans', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n' ); // Disk has dirs 01-06 but only 5 is in ROADMAP for (let i = 1; i <= 6; i++) { const padded = String(i).padStart(2, '0'); const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan'); fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary'); } const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Without the fix, dirs 06 on disk would make is_last_phase=false // With the fix, only phase 5 is in milestone, so it IS the last phase assert.strictEqual(output.is_last_phase, true, 'should be last phase — only phase 5 is in milestone'); assert.strictEqual(output.next_phase, null, 'no next phase in milestone'); }); }); // ───────────────────────────────────────────────────────────────────────────── // exact token matching (no prefix collisions) // ───────────────────────────────────────────────────────────────────────────── describe('phase resolution uses exact token matching', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('1009 must NOT match 1009A-feature-consistency when 1009 dir is absent', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency')); fs.writeFileSync(path.join(phasesDir, '1009A-feature-consistency', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 1009', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'should NOT find phase 1009 when only 1009A exists'); }); test('1009 matches 1009-pipeline-accuracy-fix when both exist', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '1009-pipeline-accuracy-fix')); fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency')); fs.writeFileSync(path.join(phasesDir, '1009-pipeline-accuracy-fix', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 1009', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'should find phase 1009'); assert.ok( output.directory.includes('1009-pipeline-accuracy-fix'), `should match 1009-pipeline-accuracy-fix, got: ${output.directory}` ); }); test('999.6 must NOT match 999.60-episode-processing when 999.6 dir is absent', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing')); fs.writeFileSync(path.join(phasesDir, '999.60-episode-processing', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 999.6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'should NOT find phase 999.6 when only 999.60 exists'); }); test('999.6 matches 999.6-ground-truth-dataset when both exist', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '999.6-ground-truth-dataset')); fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing')); fs.writeFileSync(path.join(phasesDir, '999.6-ground-truth-dataset', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 999.6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'should find phase 999.6'); assert.ok( output.directory.includes('999.6-ground-truth-dataset'), `should match 999.6-ground-truth-dataset, got: ${output.directory}` ); }); test('normal non-colliding phases still resolve', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '01-foundation')); fs.mkdirSync(path.join(phasesDir, '02-implementation')); fs.writeFileSync(path.join(phasesDir, '01-foundation', 'PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phasesDir, '02-implementation', 'PLAN.md'), '# Plan'); const r1 = runGsdTools('find-phase 1', tmpDir); assert.ok(r1.success, `Command failed for phase 1: ${r1.error}`); const o1 = JSON.parse(r1.output); assert.strictEqual(o1.found, true, 'should find phase 1'); assert.ok(o1.directory.includes('01-foundation'), `should match 01-foundation, got: ${o1.directory}`); const r2 = runGsdTools('find-phase 2', tmpDir); assert.ok(r2.success, `Command failed for phase 2: ${r2.error}`); const o2 = JSON.parse(r2.output); assert.strictEqual(o2.found, true, 'should find phase 2'); assert.ok(o2.directory.includes('02-implementation'), `should match 02-implementation, got: ${o2.directory}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase complete — Performance Metrics gate (Step 2 — Gate 4) // ───────────────────────────────────────────────────────────────────────────── describe('phase complete updates Performance Metrics', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('after cmdPhaseComplete: Performance Metrics has updated total plans count', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# Project State\n\n**Current Phase:** 2\n**Status:** Executing Phase 2\n**Total Plans in Phase:** 3\n**Current Plan:** 3\n**Completed Phases:** 0\n**Total Phases:** 3\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n` ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n'); fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan 2\n'); fs.writeFileSync(path.join(phaseDir, '02-03-PLAN.md'), '# Plan 3\n'); fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '02-03-SUMMARY.md'), '# Summary\n'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n` ); const result = runVerifiedPhaseComplete('phase complete 2', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3'); }); test('after cmdPhaseComplete: By Phase table has row for completed phase', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# Project State\n\n**Current Phase:** 1\n**Status:** Executing Phase 1\n**Total Plans in Phase:** 2\n**Current Plan:** 2\n**Completed Phases:** 0\n**Total Phases:** 2\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n` ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n` ); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(stateAfter.match(/\|\s*1\s*\|\s*2\s*\|/), 'By Phase table should have row for phase 1 with 2 plans'); // Row must appear BEFORE the next section, not after it (regression: empty table body regex) const rowIdx = stateAfter.indexOf('| 1 |'); const accIdx = stateAfter.indexOf('## Accumulated Context'); if (accIdx !== -1) { assert.ok(rowIdx < accIdx, 'By Phase row must appear before ## Accumulated Context section'); } }); }); // ───────────────────────────────────────────────────────────────────────────── // phase complete — backlog phase (999.x) exclusion (#2129) // ───────────────────────────────────────────────────────────────────────────── describe('phase complete excludes 999.x backlog from next-phase (#2129)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('next phase skips 999.x backlog dirs and falls back to roadmap', () => { // ROADMAP defines phases 1, 2, 3 and a backlog 999.1 fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 1: Setup', '- [ ] Phase 2: Core', '- [ ] Phase 3: Polish', '- [ ] Phase 999.1: Backlog idea', '', '### Phase 1: Setup', '**Goal:** Initial setup', '', '### Phase 2: Core', '**Goal:** Build core', '', '### Phase 3: Polish', '**Goal:** Polish everything', '', '### Phase 999.1: Backlog idea', '**Goal:** Parked idea', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), [ '# State', '', '**Current Phase:** 02', '**Status:** In progress', '**Current Plan:** 02-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working', ].join('\n') ); // Phase 1 and 2 exist on disk, phase 3 does NOT exist yet, 999.1 DOES exist const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const p2 = path.join(tmpDir, '.planning', 'phases', '02-core'); fs.mkdirSync(p2, { recursive: true }); fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '# Summary'); // Backlog stub on disk — this is what triggers the bug fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-idea'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Should find phase 3 from roadmap, NOT 999.1 from filesystem assert.strictEqual(output.next_phase, '3', 'next_phase should be 3, not 999.1'); assert.strictEqual(output.is_last_phase, false, 'should not be last phase'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #1962 — normalizePhaseName preserves letter suffix case // (consolidated from tests/bug-1962-phase-suffix-case.test.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('bug #1962: normalizePhaseName preserves letter suffix case', () => { test('lowercase suffix preserved: 16c → 16c', () => { assert.equal(normalizePhaseName('16c'), '16c'); }); test('uppercase suffix preserved: 16C → 16C', () => { assert.equal(normalizePhaseName('16C'), '16C'); }); test('single digit padded with lowercase suffix: 1a → 01a', () => { assert.equal(normalizePhaseName('1a'), '01a'); }); test('single digit padded with uppercase suffix: 1A → 01A', () => { assert.equal(normalizePhaseName('1A'), '01A'); }); test('no suffix unchanged: 16 → 16', () => { assert.equal(normalizePhaseName('16'), '16'); }); test('decimal suffix preserved: 16.1 → 16.1', () => { assert.equal(normalizePhaseName('16.1'), '16.1'); }); test('letter + decimal preserved: 16c.2 → 16c.2', () => { assert.equal(normalizePhaseName('16c.2'), '16c.2'); }); test('project code prefix stripped, suffix case preserved: CK-01a → 01a', () => { assert.equal(normalizePhaseName('CK-01a'), '01a'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #1998 — phase complete updates overview checkbox // (consolidated from tests/bug-1998-phase-complete-checkbox.test.cjs) // ───────────────────────────────────────────────────────────────────────────── /** * Run `gsd-tools phase complete ` for the phase-complete regression * suites and return its stdout. * * `phase complete` writes ROADMAP.md as its LAST step, after a read-heavy * parse/lock sequence (ROADMAP read, two extractCurrentMilestone STATE.md * parses, REQUIREMENTS read, phase-dir scan, STATE read — all before the single * writePlanningFileSet flush). Under the high-concurrency docker run (~672 test * files in parallel), a tight 10s timeout could fire mid-parse and SIGTERM the * subprocess BEFORE that write landed, leaving ROADMAP.md untouched. Call sites * that used a bare `catch {}` then silently proceeded to assert on the pristine * file — an intermittent "checkbox not checked" failure (bug #1998 flake). * * Two-part fix, no retry loop: * 1. A generous timeout so the test's own timer never kills the subprocess * under load (10s cold-node startup × 672-way CPU/IO contention was the * real culprit — all I/O is scoped to tmpDir, so there is no cross-process * race to blame). * 2. Never silently swallow a signal/timeout kill: it means the process was * terminated before completing its writes, so we surface it loudly with * context instead of letting it masquerade as an assertion failure. A * *clean* non-zero exit is still tolerated when `tolerateExit` is set, * because the ROADMAP write has already landed before any post-write step * that may exit non-zero in these minimal fixtures. */ function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) { writePassedVerificationForPhase(tmpDir, phase); try { return execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', phase], { cwd: tmpDir, timeout: 60000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); } catch (err) { // A signal/timeout kill terminated the process before it finished writing — // never tolerate it; surface it with whatever output was captured. if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') { throw new Error( `gsd-tools phase complete ${phase} was killed before completion ` + `(signal=${err.signal}, code=${err.code}). ` + `stdout=${err.stdout || ''} stderr=${err.stderr || ''}` ); } if (tolerateExit) { return `${err.stdout || ''}${err.stderr || ''}`; } throw err; } } describe('bug #1998: phase complete updates overview checkbox', () => { let tmpDir; let planningDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1998-')); planningDir = path.join(tmpDir, '.planning'); fs.mkdirSync(planningDir, { recursive: true }); // Minimal config fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }) ); // Minimal STATE.md fs.writeFileSync( path.join(planningDir, 'STATE.md'), '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' ); }); afterEach(() => { cleanup(tmpDir); }); test('checkbox updated when no archived milestones exist', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '## Phases', '', '- [ ] **Phase 1: Foundation** - core setup', '- [ ] **Phase 2: Features** - add features', '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', '| 2. Features | 0/1 | Pending | - |', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked'); assert.match(result, /- \[ \] \*\*Phase 2: Features\*\*/, 'phase 2 checkbox should remain unchecked'); }); test('checkbox updated when archived milestones exist in
', () => { const phasesDir = path.join(planningDir, 'phases', '01-setup'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap v2.0', '', '## Phases', '', '- [ ] **Phase 1: Setup** - initial setup', '- [ ] **Phase 2: Build** - build features', '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Setup | 0/1 | Pending | - |', '| 2. Build | 0/1 | Pending | - |', '', '
', 'v1.0 (Archived)', '', '## v1.0 Phases', '- [x] **Phase 1: Init** - initialization', '- [x] **Phase 2: Deploy** - deployment', '', '
', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked'); assert.match(result, /- \[ \] \*\*Phase 2: Build\*\*/, 'phase 2 checkbox should remain unchecked'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #2005 — phase complete inside
updates plan count // (consolidated from tests/bug-2005-phase-complete-details.test.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('bug #2005: phase complete updates plan count when milestone is inside
', () => { let tmpDir; let planningDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2005-')); planningDir = path.join(tmpDir, '.planning'); fs.mkdirSync(planningDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }) ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), '---\ncurrent_phase: 1\nstatus: executing\nmilestone: v2.0\n---\n# State\n' ); }); afterEach(() => { cleanup(tmpDir); }); test('plan count is updated when current milestone is wrapped in
', () => { const phasesDir = path.join(planningDir, 'phases', '01-setup'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '
', 'v1.0 (shipped)', '', '## v1.0 Phases', '- [x] **Phase 0: Bootstrap** - shipped', '', '
', '', '
', 'v2.0 (in progress)', '', '## v2.0 Phases', '', '- [ ] **Phase 1: Setup** - initial setup', '- [ ] **Phase 2: Build** - build features', '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Setup | 0/1 | Pending | - |', '| 2. Build | 0/1 | Pending | - |', '', '### Phase 1: Setup', '', '**Plans:** 0/1 plans complete', '', '### Phase 2: Build', '', '**Plans:** 0/1 plans complete', '', '
', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match( result, /\*\*Plans:\*\*\s*1\/1 plans complete/, 'plan count in Phase 1 section must be updated to 1/1 plans complete' ); assert.match( result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'Phase 1 checkbox must be checked after completion' ); assert.match( result, /- \[ \] \*\*Phase 2: Build\*\*/, 'Phase 2 checkbox must remain unchecked' ); }); test('phase complete with all milestones in
does not corrupt phase 2 plan count', () => { const phasesDir = path.join(planningDir, 'phases', '01-setup'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '
', 'v2.0 (in progress)', '', '## v2.0 Phases', '', '- [ ] **Phase 1: Setup** - initial setup', '- [ ] **Phase 2: Build** - build features', '', '### Phase 1: Setup', '', '**Plans:** 0/1 plans complete', '', '### Phase 2: Build', '', '**Plans:** 0/2 plans complete', '', '
', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match( result, /Phase 2: Build[\s\S]*?\*\*Plans:\*\*\s*0\/2 plans complete/, 'Phase 2 plan count must remain 0/2 (untouched)' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #2526 — phase complete warns about unregistered REQ-IDs // (consolidated from tests/bug-2526-phase-complete-req-discovery.test.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => { let tmpDir; let planningDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-')); planningDir = path.join(tmpDir, '.planning'); fs.mkdirSync(planningDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: '' }) ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' ); }); afterEach(() => { cleanup(tmpDir); }); test('emits warning for REQ-IDs in body but missing from Traceability table', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Build core', '**Requirements:** REQ-001', '**Plans:** 1 plans', '', 'Plans:', '- [x] 01-1-PLAN.md', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', ].join('\n')); const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); fs.writeFileSync(reqPath, [ '# Requirements', '', '## Functional Requirements', '', '- [x] **REQ-001**: Core data model', '- [ ] **REQ-002**: User authentication', '- [ ] **REQ-003**: API endpoints', '', '## Traceability', '', '| REQ-ID | Phase | Status |', '|--------|-------|--------|', '| REQ-001 | 1 | Pending |', ].join('\n')); const combined = runPhaseComplete(tmpDir); assert.match(combined, /REQ-002/, 'output should mention REQ-002 as missing from Traceability table'); assert.match(combined, /REQ-003/, 'output should mention REQ-003 as missing from Traceability table'); }); test('no warning when all body REQ-IDs are present in Traceability table', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Build core', '**Requirements:** REQ-001, REQ-002', '**Plans:** 1 plans', '', 'Plans:', '- [x] 01-1-PLAN.md', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', ].join('\n')); const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); fs.writeFileSync(reqPath, [ '# Requirements', '', '## Functional Requirements', '', '- [x] **REQ-001**: Core data model', '- [x] **REQ-002**: User authentication', '', '## Traceability', '', '| REQ-ID | Phase | Status |', '|--------|-------|--------|', '| REQ-001 | 1 | Pending |', '| REQ-002 | 1 | Pending |', ].join('\n')); const combined = runPhaseComplete(tmpDir); assert.doesNotMatch( combined, /unregistered|missing.*traceability|not in.*traceability/i, 'no warning should appear when all REQ-IDs are in the table' ); }); test('warning includes all missing REQ-IDs, not just the first', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Build core', '**Requirements:** REQ-001', '**Plans:** 1 plans', '', 'Plans:', '- [x] 01-1-PLAN.md', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', ].join('\n')); const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); fs.writeFileSync(reqPath, [ '# Requirements', '', '- [x] **REQ-001**: Core data model', '- [ ] **REQ-002**: User auth', '- [ ] **REQ-003**: API', '- [ ] **REQ-004**: Reports', '', '## Traceability', '', '| REQ-ID | Phase | Status |', '|--------|-------|--------|', '| REQ-001 | 1 | Pending |', ].join('\n')); const combined = runPhaseComplete(tmpDir); assert.match(combined, /REQ-002/, 'should warn about REQ-002'); assert.match(combined, /REQ-003/, 'should warn about REQ-003'); assert.match(combined, /REQ-004/, 'should warn about REQ-004'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3287 — phase-dir prefix parity across creation paths // (consolidated from tests/bug-3287-phase-dir-prefix-parity.test.cjs) // ───────────────────────────────────────────────────────────────────────────── // ─── shared fixture for bug-3287 ───────────────────────────────────────────── 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'), ); } 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)}`, ); }); }); 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); 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)'); 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', () => { 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)}`, ); 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}"`, ); }); }); 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); 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)'); 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}"`, ); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3298 — phase-dir prefix drift in workflows // (consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { const PMG_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-milestone-gaps.md'); const IMPORT_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'import.md'); const BACKLOG_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'add-backlog.md'); function readWorkflow(filePath) { try { return fs.readFileSync(filePath, 'utf8'); } catch (err) { throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`); } } function containsBareTemplateMkdir(content) { return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content); } function containsBareShellVarMkdir(content) { return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content) || /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content); } 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', ); }); }); 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', ); }); }); 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', ); }); }); } // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3517 — phase.complete leaves STATE.md with stale fields // (consolidated from tests/bug-3517-phase-complete-state-md-staleness.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { function runSdkQuery(args, cwd) { if (Array.isArray(args) && args[0] === 'phase.complete') { writePassedVerificationForPhase(cwd, args[1]); } const result = runGsdTools(args, cwd); if (!result.success) return { success: false, error: result.error }; try { const parsed = JSON.parse(result.output || '{}'); return { success: true, data: parsed }; } catch (err) { return { success: false, error: err.message }; } } function setupPhase3517Project(tmpDir) { const planningDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planningDir, 'phases'); fs.mkdirSync(planningDir, { recursive: true }); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }), ); const roadmap = [ '# Roadmap', '', '## Current Milestone: v3.0', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 4. | 3/3 | Complete | 2026-04-01 |', '| 5. | 7/7 | In Progress | |', '| 6. | 0/5 | Not Started | |', '', '- [x] Phase 4: Foundation (completed 2026-04-01)', '- [ ] Phase 5: Core API', '- [ ] Phase 6: Integration', '', '### Phase 4: Foundation', '', '**Goal:** Foundation work', '**Plans:** 3/3 plans complete', '', '### Phase 5: Core API', '', '**Goal:** Build core API layer', '**Plans:** 7 plans', '', 'Plans:', '- [ ] 05-01 plan', '- [ ] 05-02 plan', '- [ ] 05-03 plan', '- [ ] 05-04 plan', '- [ ] 05-05 plan', '- [ ] 05-06 plan', '- [ ] 05-07 plan', '', '### Phase 6: Integration', '', '**Goal:** Integration work', '**Plans:** 5 plans', '', '---', '*Last updated: 2026-05-14*', ].join('\n'); fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); const state = [ '---', 'gsd_state_version: 1.0', 'milestone: v3.0', 'milestone_name: Core Platform', 'status: executing', 'stopped_at: Completed 05-03-PLAN.md', 'last_updated: 2026-05-10T08:00:00.000Z', 'progress:', ' total_phases: 3', ' completed_phases: 1', ' total_plans: 15', ' completed_plans: 6', ' percent: 33', '---', '', '# Project State', '', '## Current Position', '', '**Current focus:** Phase 5 — Core API', 'Phase: 5 of 3 (Core API) — EXECUTING', 'Plan: 7 of 7', 'Status: Executing Phase 5', 'Last activity: 2026-05-10', '', '## Progress', '', 'Progress: [████████████░░░░] 40% (1/3 phases complete before Phase 5 closeout)', '', '## Performance Metrics', '', '**Velocity:**', '', '- Total plans completed: 6', '- Average duration: 2h', '- Total execution time: 14 hours', '- Window: 2026-04-01 to 2026-05-10', '', '**By Phase:**', '', '| Phase | Plans | Total | Avg/Plan |', '|-------|-------|-------|----------|', '| 4 | 3 | - | - |', '', '## Session Continuity', '', 'Last session: 2026-05-10T08:00:00.000Z', 'Stopped at: Completed 05-07-PLAN.md', ].join('\n'); fs.writeFileSync(path.join(planningDir, 'STATE.md'), state); const phase5Dir = path.join(phasesDir, '05-core-api'); fs.mkdirSync(phase5Dir, { recursive: true }); for (let i = 1; i <= 7; i++) { const padded = String(i).padStart(2, '0'); fs.writeFileSync(path.join(phase5Dir, `05-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); fs.writeFileSync(path.join(phase5Dir, `05-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); } const phase4Dir = path.join(phasesDir, '04-foundation'); fs.mkdirSync(phase4Dir, { recursive: true }); for (let i = 1; i <= 3; i++) { const padded = String(i).padStart(2, '0'); fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); } const phase6Dir = path.join(phasesDir, '06-integration'); fs.mkdirSync(phase6Dir, { recursive: true }); return { planningDir, phase5Dir }; } function setupPhase1316Project(tmpDir) { const planningDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planningDir, 'phases'); fs.mkdirSync(planningDir, { recursive: true }); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'ROADMAP.md'), [ '# Roadmap', '', '## Current Milestone: v3.0', '', '- [ ] Phase 32: Backlog-Closeout Lib Extraction', '- [ ] Phase 33: Follow Up Implementation', '', '### Phase 32: Backlog-Closeout Lib Extraction', '**Goal:** Complete closeout extraction', '**Plans:** 1 plans', '', '### Phase 33: Follow Up Implementation', '**Goal:** Continue implementation', ].join('\n'), ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), [ '---', 'gsd_state_version: 1.0', 'status: executing', 'current_phase: "32"', 'last_activity: "2026-06-14"', 'progress:', ' total_phases: 2', ' completed_phases: 0', ' total_plans: 1', ' completed_plans: 0', ' percent: 0', '---', '', '# Project State', '', '## Current Position', '', 'Phase: 32 — Backlog-Closeout Lib Extraction', 'Plan: 1 of 1', 'Status: Executing Phase 32', 'Last activity: 2026-06-14 — recorded planning complete', '', '## Session', '', 'Last session: 2026-06-14T00:00:00.000Z', ].join('\n'), ); const phase32Dir = path.join(phasesDir, '32-backlog-closeout-lib-extraction'); fs.mkdirSync(phase32Dir, { recursive: true }); fs.writeFileSync(path.join(phase32Dir, '32-01-PLAN.md'), '# Plan', 'utf8'); fs.writeFileSync(path.join(phase32Dir, '32-01-SUMMARY.md'), '# Summary', 'utf8'); fs.mkdirSync(path.join(phasesDir, '33-follow-up-implementation'), { recursive: true }); return { planningDir }; } describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3517-')); }); afterEach(() => { cleanup(tmpDir); }); test('completed_phases is derived from ROADMAP, not blindly incremented (idempotency)', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r1 = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r1.success, `first call failed: ${r1.error}`); const stateAfter1 = fs.readFileSync(statePath, 'utf8'); const match1 = stateAfter1.match(/completed_phases:\s*(\d+)/); assert.ok(match1, 'completed_phases not found in frontmatter after first call'); assert.equal( Number(match1[1]), 2, `After first call: completed_phases should be 2 (derived from ROADMAP: phases 4 and 5 complete), got ${match1[1]}`, ); const r2 = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r2.success, `second call failed: ${r2.error}`); const stateAfter2 = fs.readFileSync(statePath, 'utf8'); const match2 = stateAfter2.match(/completed_phases:\s*(\d+)/); assert.ok(match2, 'completed_phases not found in frontmatter after second call'); assert.equal( Number(match2[1]), 2, `After second call (same phase): completed_phases must remain 2 (idempotent), got ${match2[1]}`, ); }); test('frontmatter stopped_at is updated after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const stoppedMatch = state.match(/stopped_at:\s*(.+)/); assert.ok(stoppedMatch, 'stopped_at not found in frontmatter'); assert.ok( !stoppedMatch[1].includes('05-03-PLAN.md'), `stopped_at should not still say "Completed 05-03-PLAN.md" — got: ${stoppedMatch[1]}`, ); assert.ok( stoppedMatch[1].toLowerCase().includes('phase 5') || stoppedMatch[1].toLowerCase().includes('complete'), `stopped_at should reference phase 5 completion, got: ${stoppedMatch[1]}`, ); }); test('frontmatter last_updated is refreshed to today after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const lastUpdatedMatch = state.match(/last_updated:\s*(.+)/); assert.ok(lastUpdatedMatch, 'last_updated not found in frontmatter'); const raw = lastUpdatedMatch[1].trim().replace(/^"(.*)"$/, '$1'); // Must have been refreshed — not the stale seed value from setupPhase3517Project assert.notEqual( raw, '2026-05-10T08:00:00.000Z', `last_updated must be refreshed, but it is still the stale seed value: ${raw}`, ); // Must parse as a valid ISO timestamp const updatedAt = new Date(raw); assert.ok( !isNaN(updatedAt.getTime()), `last_updated must be a valid ISO timestamp, got: ${raw}`, ); // Date portion must equal today's UTC date — avoids any wall-clock window comparison const todayUtc = new Date().toISOString().slice(0, 10); const updatedDateUtc = updatedAt.toISOString().slice(0, 10); assert.equal( updatedDateUtc, todayUtc, `last_updated date portion must equal today's UTC date (${todayUtc}), got: ${updatedDateUtc}`, ); }); test('frontmatter total_plans is updated from ROADMAP plan counts after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const match = state.match(/total_plans:\s*(\d+)/); assert.ok(match, 'total_plans not found in frontmatter'); const totalPlans = Number(match[1]); assert.ok(Number.isFinite(totalPlans) && totalPlans > 0, `total_plans must be a positive number, got: ${match[1]}`); }); test('frontmatter completed_plans is updated from SUMMARY file count after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const match = state.match(/completed_plans:\s*(\d+)/); assert.ok(match, 'completed_plans not found in frontmatter'); const completedPlans = Number(match[1]); assert.equal( completedPlans, 10, `completed_plans should be 10 (3 phase-4 summaries + 7 phase-5 summaries), got: ${completedPlans}`, ); }); test('frontmatter percent is recomputed from fresh derived counts', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const match = state.match(/percent:\s*(\d+)/); assert.ok(match, 'percent not found in frontmatter'); assert.equal(Number(match[1]), 67, `percent should be 67 (2/3 phases), got: ${match[1]}`); }); test('state frontmatter and numeric phase line reflect next phase after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); assert.match(state, /completed_phases:\s*2/, 'completed_phases must be updated in frontmatter'); assert.match(state, /Phase:\s*0?6\b/, 'numeric Phase line should advance to phase 6'); }); test('prose-block STATE keeps next phase name without field-miss warnings (#1316)', () => { const { planningDir } = setupPhase1316Project(tmpDir); writePassedVerificationForPhase(tmpDir, '32'); const result = spawnSync(process.execPath, [GSD_TOOLS_BIN, 'phase', 'complete', '32'], { cwd: tmpDir, encoding: 'utf8', env: process.env, }); assert.strictEqual(result.status, 0, `phase complete failed: ${result.stderr || result.stdout}`); assert.ok( !result.stderr.includes('Current Phase Name'), `phase.complete must not warn about missing Current Phase Name on prose-block STATE.md; stderr:\n${result.stderr}`, ); assert.ok( !result.stderr.includes('Last Activity Description'), `phase.complete must not warn about missing Last Activity Description on prose-block STATE.md; stderr:\n${result.stderr}`, ); const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8'); assert.match(state, /current_phase:\s*"?33"?/, 'current_phase frontmatter must advance to 33'); assert.match( state, /^Phase:\s*33\s+—\s+Follow Up Implementation\b/m, `Current Position Phase line must keep the next phase name; state:\n${state}`, ); assert.match( state, /^Last activity:\s*\d{4}-\d{2}-\d{2}\s+—\s+Phase 32 complete/m, `Last activity line must use the template em-dash delimiter with narrative; state:\n${state}`, ); }); test('body By Phase table row for completed phase shows correct plan count', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); assert.match( state, /\|\s*5\s*\|\s*7\s*\|/, `By Phase table should have a row for phase 5 with 7 summaries.\nState:\n${state}`, ); }); test('full consistency check: all STATE.md fields are coherent after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); assert.equal(r.data?.state_updated, true, 'state_updated must be true'); const state = fs.readFileSync(statePath, 'utf8'); assert.match(state, /completed_phases:\s*2/, 'completed_phases must be 2 (4 and 5 complete)'); assert.match(state, /percent:\s*67/, 'percent must be 67%'); const hasPhase6 = /Phase:\s*0?6/.test(state) || /current_phase:\s*0?6/.test(state); assert.ok(hasPhase6, `STATE.md must reference Phase 6 as current after completing Phase 5.\nState:\n${state}`); }); }); } // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3601 — phase remove preserves peer-depth decimal sections // (consolidated from tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { process.env.GSD_TEST_MODE = '1'; function writeRoadmapForRemove(tmpDir, body) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); } function writeStateForRemove(tmpDir, version) { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `---\nmilestone: ${version}\n---\n`, ); } function ensurePhaseDir(tmpDir, name) { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true }); } function getPhase(tmpDir, phaseNum) { const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir); if (!r.success) return { found: false, error: r.error }; return JSON.parse(r.output); } describe('bug #3601: phase remove preserves peer-depth decimal sections', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('bug-3601-'); }); afterEach(() => { cleanup(tmpDir); }); test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 2: Parent', '**Goal:** RemoveMeGoal', '', '### Phase 2.1: Follow-up', '**Goal:** PreserveDecimalGoal', '', '### Phase 3: Trailing', '**Goal:** PreserveTrailingGoal', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '02-parent'); ensurePhaseDir(tmpDir, '02.1-follow-up'); ensurePhaseDir(tmpDir, '03-trailing'); const r = runGsdTools(['phase', 'remove', '2'], tmpDir); assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); const decimal = getPhase(tmpDir, '2.1'); assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2'); assert.strictEqual(decimal.phase_name, 'Follow-up'); assert.strictEqual(decimal.goal, 'PreserveDecimalGoal'); const renumbered = getPhase(tmpDir, '2'); assert.strictEqual(renumbered.found, true); assert.strictEqual(renumbered.phase_name, 'Trailing'); assert.strictEqual( renumbered.goal, 'PreserveTrailingGoal', 'Phase 3 → Phase 2 renumber did not carry the right section content', ); const parentLookup = getPhase(tmpDir, '3'); assert.notStrictEqual( parentLookup.goal, 'RemoveMeGoal', 'removed parent goal reappeared under a phase header', ); }); test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 5: Parent', '**Goal:** RemoveParent', '', '### Phase 5.1: First child', '**Goal:** ChildAGoal', '', '### Phase 5.2: Second child', '**Goal:** ChildBGoal', '', '### Phase 6: Tail', '**Goal:** TailGoal', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '05-parent'); ensurePhaseDir(tmpDir, '05.1-first-child'); ensurePhaseDir(tmpDir, '05.2-second-child'); ensurePhaseDir(tmpDir, '06-tail'); const r = runGsdTools(['phase', 'remove', '5'], tmpDir); assert.ok(r.success); const decimalA = getPhase(tmpDir, '5.1'); assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted'); assert.strictEqual(decimalA.goal, 'ChildAGoal'); const decimalB = getPhase(tmpDir, '5.2'); assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted'); assert.strictEqual(decimalB.goal, 'ChildBGoal'); const tail = getPhase(tmpDir, '5'); assert.strictEqual(tail.found, true); assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3602 — phase remove renumbers slugged plan references // (consolidated from tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs) // ───────────────────────────────────────────────────────────────────────────── function ensurePlanFile(tmpDir, phaseDirName, planName) { const p = path.join(tmpDir, '.planning', 'phases', phaseDirName, planName); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, '# Plan\n'); } describe('bug #3602: phase remove renumbers slugged plan references in ROADMAP', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('bug-3602-'); }); afterEach(() => { cleanup(tmpDir); }); test('slugged PLAN reference (07-01-cherry-pick-foundation-PLAN.md) is renumbered to 06-01-…', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0', '', '### Phase 6: Old Work', '**Goal:** RemoveThisGoal', '', '### Phase 7: New Work', '**Goal:** Plans: 07-01-cherry-pick-foundation-PLAN.md and 07-02-finish-it-SUMMARY.md', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '06-old'); ensurePhaseDir(tmpDir, '07-new'); ensurePlanFile(tmpDir, '07-new', '07-01-cherry-pick-foundation-PLAN.md'); ensurePlanFile(tmpDir, '07-new', '07-02-finish-it-SUMMARY.md'); const r = runGsdTools(['phase', 'remove', '6'], tmpDir); assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); const phase6 = getPhase(tmpDir, '6'); assert.strictEqual(phase6.found, true); assert.strictEqual(phase6.phase_name, 'New Work'); assert.ok( phase6.goal.includes('06-01-cherry-pick-foundation-PLAN.md'), `Plans reference for slugged PLAN was not renumbered. Goal: ${phase6.goal}`, ); assert.ok( phase6.goal.includes('06-02-finish-it-SUMMARY.md'), `Plans reference for slugged SUMMARY was not renumbered. Goal: ${phase6.goal}`, ); assert.ok( !phase6.goal.includes('07-01'), `stale 07-01 prefix remains in ROADMAP. Goal: ${phase6.goal}`, ); assert.ok( !phase6.goal.includes('07-02'), `stale 07-02 prefix remains in ROADMAP. Goal: ${phase6.goal}`, ); }); test('compact PLAN/SUMMARY reference (07-01-PLAN.md) still renumbers (#3601 contract preserved)', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0', '', '### Phase 6: Old', '**Goal:** RemoveGoal', '', '### Phase 7: New', '**Goal:** Plans: 07-01-PLAN.md and 07-02-SUMMARY.md', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '06-old'); ensurePhaseDir(tmpDir, '07-new'); ensurePlanFile(tmpDir, '07-new', '07-01-PLAN.md'); ensurePlanFile(tmpDir, '07-new', '07-02-SUMMARY.md'); const r = runGsdTools(['phase', 'remove', '6'], tmpDir); assert.ok(r.success); const phase6 = getPhase(tmpDir, '6'); assert.strictEqual(phase6.found, true); assert.ok(phase6.goal.includes('06-01-PLAN.md')); assert.ok(phase6.goal.includes('06-02-SUMMARY.md')); assert.ok(!phase6.goal.includes('07-01-PLAN.md')); assert.ok(!phase6.goal.includes('07-02-SUMMARY.md')); }); test('does NOT renumber values that look like phase-plan tokens but are not (e.g. 2026-01-01 dates)', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0', '', '### Phase 6: Old', '**Goal:** RemoveGoal', '', '### Phase 7: Date safety', '**Goal:** Created 2026-01-01 and tagged v1-2-3 — must not renumber', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '06-old'); ensurePhaseDir(tmpDir, '07-new'); const r = runGsdTools(['phase', 'remove', '6'], tmpDir); assert.ok(r.success); const phase6 = getPhase(tmpDir, '6'); assert.strictEqual(phase6.found, true); assert.ok( phase6.goal.includes('2026-01-01'), `ISO date 2026-01-01 was wrongly modified. Goal: ${phase6.goal}`, ); assert.ok( phase6.goal.includes('v1-2-3'), `version-tag v1-2-3 was wrongly modified. Goal: ${phase6.goal}`, ); }); }); } // ───────────────────────────────────────────────────────────────────────────── // feature: phase complete auto-prune (#2087) // (consolidated from tests/phase-complete-auto-prune.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { function writeConfigForAutoPrune(tmpDir, config) { fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2)); } function writeStateMdForAutoPrune(tmpDir, content) { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content); } function readStateMdForAutoPrune(tmpDir) { return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); } function writeRoadmapForAutoPrune(tmpDir, content) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); } function setupPhaseForAutoPrune(tmpDir, phaseNum, planCount) { const phasesDir = path.join(tmpDir, '.planning', 'phases'); const phaseDir = path.join(phasesDir, `${String(phaseNum).padStart(2, '0')}-test-phase`); fs.mkdirSync(phaseDir, { recursive: true }); for (let i = 1; i <= planCount; i++) { const planId = `${String(phaseNum).padStart(2, '0')}-${String(i).padStart(2, '0')}`; fs.writeFileSync(path.join(phaseDir, `${planId}-PLAN.md`), `# Plan ${planId}\n`); fs.writeFileSync(path.join(phaseDir, `${planId}-SUMMARY.md`), `# Summary ${planId}\n`); } } describe('phase complete auto-prune (#2087)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('prunes STATE.md automatically when auto_prune_state is true', () => { writeConfigForAutoPrune(tmpDir, { workflow: { auto_prune_state: true }, }); writeStateMdForAutoPrune(tmpDir, [ '# Session State', '', '**Current Phase:** 6', '**Status:** Executing', '', '## Decisions', '', '- [Phase 1]: Old decision from phase 1', '- [Phase 2]: Old decision from phase 2', '- [Phase 5]: Recent decision', '- [Phase 6]: Current decision', '', ].join('\n')); writeRoadmapForAutoPrune(tmpDir, [ '# Roadmap', '', '## Phase 6: Test Phase', '', '**Plans:** 0/2', '', ].join('\n')); setupPhaseForAutoPrune(tmpDir, 6, 2); const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/); assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/); assert.match(newState, /\[Phase 5\]: Recent decision/); assert.match(newState, /\[Phase 6\]: Current decision/); }); test('does NOT prune when auto_prune_state is false (default)', () => { writeConfigForAutoPrune(tmpDir, { workflow: { auto_prune_state: false }, }); writeStateMdForAutoPrune(tmpDir, [ '# Session State', '', '**Current Phase:** 6', '**Status:** Executing', '', '## Decisions', '', '- [Phase 1]: Old decision from phase 1', '- [Phase 5]: Recent decision', '- [Phase 6]: Current decision', '', ].join('\n')); writeRoadmapForAutoPrune(tmpDir, [ '# Roadmap', '', '## Phase 6: Test Phase', '', '**Plans:** 0/2', '', ].join('\n')); setupPhaseForAutoPrune(tmpDir, 6, 2); const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); assert.match(newState, /\[Phase 1\]: Old decision/); }); test('does NOT prune when auto_prune_state is absent from config', () => { writeConfigForAutoPrune(tmpDir, { workflow: {}, }); writeStateMdForAutoPrune(tmpDir, [ '# Session State', '', '**Current Phase:** 6', '**Status:** Executing', '', '## Decisions', '', '- [Phase 1]: Old decision from phase 1', '- [Phase 6]: Current decision', '', ].join('\n')); writeRoadmapForAutoPrune(tmpDir, [ '# Roadmap', '', '## Phase 6: Test Phase', '', '**Plans:** 0/2', '', ].join('\n')); setupPhaseForAutoPrune(tmpDir, 6, 2); const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); assert.match(newState, /\[Phase 1\]: Old decision/); }); }); } // ───────────────────────────────────────────────────────────────────────────── // bug #1229: phase.add bullet-only phase collision // ───────────────────────────────────────────────────────────────────────────── // Count canonical Phase N entries in roadmap content (header or bullet form). // Only counts "### Phase N:" headers and "- [ ] **Phase N:" bullet entries. // Does NOT count references like "**Depends on:** Phase N". function countBug1229PhaseNumber(roadmapContent, n) { let count = 0; const headerRe = new RegExp('^#{2,4}\\s*Phase\\s+' + n + '[A-Z]?(?:\\.\\d+)*:', 'gim'); const bulletRe = new RegExp( '^[ \\t]*-[ \\t]*\\[[^\\]]*\\][ \\t]*\\*{0,2}Phase[ \\t]+' + n + '(?=[:.\\ \\t*]|$)', 'gim', ); const headerMatches = roadmapContent.match(headerRe); const bulletMatches = roadmapContent.match(bulletRe); if (headerMatches) count += headerMatches.length; if (bulletMatches) count += bulletMatches.length; return count; } describe('bug #1229: phase.add must count bullet-only phases to avoid number collision', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('bullet-only Phase 11 is counted: next add gets Phase 12, not 11', () => { // ROADMAP has Phases 1-3 as full sections and Phase 11 as bullet-only. // Before the fix, maxPhase resolved to 3 (header scan) and phase.add // silently produced Phase 4, then on a second add would produce Phase 11 // — or if headers went to 10, it would produce Phase 11 colliding with // the existing bullet. const roadmap = [ '# Roadmap v1.0', '', '## Phases', '', '- [ ] **Phase 1: Foundation**', '- [ ] **Phase 2: Core**', '- [x] **Phase 3: Done**', '- [ ] **Phase 11: Communications / Zoho Sync**', '', '### Phase 1: Foundation', '', '**Goal:** Build foundations', '', '### Phase 2: Core', '', '**Goal:** Core work', '', '### Phase 3: Done', '', '**Goal:** Completed work', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); // Create disk dirs for phases 1, 2, 3 (not 11 -- that is bullet-only) fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-core'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-done'), { recursive: true }); const result = runGsdTools('phase add New Feature', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 12, `Expected phase 12 (bullet-only Phase 11 must be counted), got ${output.phase_number}`, ); // Verify no duplicate Phase 11 written const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const phase11Count = countBug1229PhaseNumber(updatedRoadmap, 11); assert.ok( phase11Count === 1, `ROADMAP must have exactly 1 occurrence of Phase 11 (no duplicate), found ${phase11Count}`, ); // Verify Phase 12 was written assert.ok( updatedRoadmap.includes('### Phase 12:'), 'ROADMAP must contain new ### Phase 12: entry', ); // Verify directory was created at 12, not 11 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-new-feature')), 'phases/12-new-feature directory must be created', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-new-feature')), 'phases/11-new-feature must NOT be created (collision guard)', ); }); test('[x] checkbox variant bullet phase is counted', () => { // Phase 5 exists only as a [x] bullet (completed, no dir, no header) const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: API', '', '**Goal:** Build', '', '### Phase 3: UI', '', '**Goal:** Interfaces', '', '### Phase 4: Deploy', '', '**Goal:** Ship it', '', '- [x] **Phase 5: Post-launch Cleanup**', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Follow-up Work', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 6, `Expected phase 6 ([x] bullet-only Phase 5 must be counted), got ${output.phase_number}`, ); }); test('[~] checkbox variant bullet phase is counted', () => { // Phase 7 exists only as a [~] bullet (in-progress, no dir, no header) const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '- [~] **Phase 7: Partial Work**', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Next Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 8, `Expected phase 8 ([~] bullet-only Phase 7 must be counted), got ${output.phase_number}`, ); }); test('baseline: no bullet-only phases -- existing behavior preserved', () => { const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: API', '', '**Goal:** Build', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Third Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 3, `Expected phase 3 (normal sequential add), got ${output.phase_number}`, ); }); test('bullet without ** bold markers is counted', () => { // Phase 6 as plain bullet without ** markdown bold const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: Core', '', '**Goal:** Core', '', '- [ ] Phase 6: Plain bullet no bold', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Another Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 7, `Expected phase 7 (plain-bullet Phase 6 must be counted), got ${output.phase_number}`, ); }); test('titleless bold bullet "- [ ] **Phase 11**" is counted: next add gets Phase 12', () => { // Regression for the adversarial-review finding: the original bulletPattern // required a colon or whitespace after the digits, so "- [ ] **Phase 11**" // (bold-close immediately after the number) was silently skipped and phase.add // would assign Phase 11 again — the exact collision class bug #1229 fixes. const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: Core', '', '**Goal:** Core', '', '- [ ] **Phase 11**', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Titleless Bold Follow-up', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 12, `Expected phase 12 (titleless bold bullet "**Phase 11**" must be counted), got ${output.phase_number}`, ); const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( updatedRoadmap.includes('### Phase 12:'), 'ROADMAP must contain new ### Phase 12: entry', ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-titleless-bold-follow-up')), 'phases/12-titleless-bold-follow-up directory must be created', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-titleless-bold-follow-up')), 'phases/11-titleless-bold-follow-up must NOT be created (collision guard)', ); }); test('EOL bullet "- [ ] Phase 11" (no title, no bold) is counted: next add gets Phase 12', () => { // Regression: "- [ ] Phase 11" at end-of-line was not matched by the original // pattern whose trailing [:\s] requires at least one character after the digits. const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: Core', '', '**Goal:** Core', '', '- [ ] Phase 11', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add EOL Follow-up', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 12, `Expected phase 12 (EOL bullet "Phase 11" must be counted), got ${output.phase_number}`, ); const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( updatedRoadmap.includes('### Phase 12:'), 'ROADMAP must contain new ### Phase 12: entry', ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-eol-follow-up')), 'phases/12-eol-follow-up directory must be created', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-eol-follow-up')), 'phases/11-eol-follow-up must NOT be created (collision guard)', ); }); });