// 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. /** * GSD Tools Tests - Phase */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); 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', () => { 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 --- ## Task 1: Schema ` ); fs.writeFileSync( path.join(phaseDir, '03-02-PLAN.md'), `--- wave: 1 autonomous: true objective: Auth setup --- ## Task 1: JWT ` ); fs.writeFileSync( path.join(phaseDir, '03-03-PLAN.md'), `--- wave: 2 autonomous: false objective: API routes --- ## Task 1: Routes ` ); 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'); }); 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'); }); 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'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 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/get-shit-done/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('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' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // 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'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 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)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\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'); 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('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-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'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase complete command // ───────────────────────────────────────────────────────────────────────────── 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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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('../get-shit-done/bin/lib/core.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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('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'); }); }); // ───────────────────────────────────────────────────────────────────────────── // milestone complete command // ─────────────────────────────────────────────────────────────────────────────