From fa2e15688706ee66050504a3092033a40f1d539f Mon Sep 17 00:00:00 2001 From: Tyler Satre Date: Fri, 20 Feb 2026 10:30:54 -0500 Subject: [PATCH 1/3] refactor: split gsd-tools.test.cjs into domain test files Move 81 tests (18 describe blocks) from single monolithic test file into 7 domain-specific test files under tests/ with shared helpers. Test parity verified: 81/81 pass before and after split. Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/gsd-tools.test.cjs | 2302 -------------------------- tests/commands.test.cjs | 625 +++++++ tests/helpers.cjs | 40 + tests/init.test.cjs | 105 ++ tests/milestone.test.cjs | 98 ++ tests/phase.test.cjs | 1013 ++++++++++++ tests/roadmap.test.cjs | 265 +++ tests/state.test.cjs | 156 ++ tests/verify.test.cjs | 80 + 9 files changed, 2382 insertions(+), 2302 deletions(-) delete mode 100644 get-shit-done/bin/gsd-tools.test.cjs create mode 100644 tests/commands.test.cjs create mode 100644 tests/helpers.cjs create mode 100644 tests/init.test.cjs create mode 100644 tests/milestone.test.cjs create mode 100644 tests/phase.test.cjs create mode 100644 tests/roadmap.test.cjs create mode 100644 tests/state.test.cjs create mode 100644 tests/verify.test.cjs diff --git a/get-shit-done/bin/gsd-tools.test.cjs b/get-shit-done/bin/gsd-tools.test.cjs deleted file mode 100644 index 2136e0369..000000000 --- a/get-shit-done/bin/gsd-tools.test.cjs +++ /dev/null @@ -1,2302 +0,0 @@ -/** - * GSD Tools Tests - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const TOOLS_PATH = path.join(__dirname, 'gsd-tools.cjs'); - -// Helper to run gsd-tools command -function runGsdTools(args, cwd = process.cwd()) { - try { - const result = execSync(`node "${TOOLS_PATH}" ${args}`, { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - return { success: true, output: result.trim() }; - } catch (err) { - return { - success: false, - output: err.stdout?.toString().trim() || '', - error: err.stderr?.toString().trim() || err.message, - }; - } -} - -// Create temp directory structure -function createTempProject() { - const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); - return tmpDir; -} - -function cleanup(tmpDir) { - fs.rmSync(tmpDir, { recursive: true, force: true }); -} - -describe('history-digest command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('empty phases directory returns valid schema', () => { - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - - assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object'); - assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array'); - assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array'); - }); - - test('nested frontmatter fields extracted correctly', () => { - // Create phase directory with SUMMARY containing nested frontmatter - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - const summaryContent = `--- -phase: "01" -name: "Foundation Setup" -dependency-graph: - provides: - - "Database schema" - - "Auth system" - affects: - - "API layer" -tech-stack: - added: - - "prisma" - - "jose" -patterns-established: - - "Repository pattern" - - "JWT auth flow" -key-decisions: - - "Use Prisma over Drizzle" - - "JWT in httpOnly cookies" ---- - -# Summary content here -`; - - fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - - // Check nested dependency-graph.provides - assert.ok(digest.phases['01'], 'Phase 01 should exist'); - assert.deepStrictEqual( - digest.phases['01'].provides.sort(), - ['Auth system', 'Database schema'], - 'provides should contain nested values' - ); - - // Check nested dependency-graph.affects - assert.deepStrictEqual( - digest.phases['01'].affects, - ['API layer'], - 'affects should contain nested values' - ); - - // Check nested tech-stack.added - assert.deepStrictEqual( - digest.tech_stack.sort(), - ['jose', 'prisma'], - 'tech_stack should contain nested values' - ); - - // Check patterns-established (flat array) - assert.deepStrictEqual( - digest.phases['01'].patterns.sort(), - ['JWT auth flow', 'Repository pattern'], - 'patterns should be extracted' - ); - - // Check key-decisions - assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions'); - assert.ok( - digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'), - 'Should contain first decision' - ); - }); - - test('multiple phases merged into single digest', () => { - // Create phase 01 - const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.writeFileSync( - path.join(phase01Dir, '01-01-SUMMARY.md'), - `--- -phase: "01" -name: "Foundation" -provides: - - "Database" -patterns-established: - - "Pattern A" -key-decisions: - - "Decision 1" ---- -` - ); - - // Create phase 02 - const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api'); - fs.mkdirSync(phase02Dir, { recursive: true }); - fs.writeFileSync( - path.join(phase02Dir, '02-01-SUMMARY.md'), - `--- -phase: "02" -name: "API" -provides: - - "REST endpoints" -patterns-established: - - "Pattern B" -key-decisions: - - "Decision 2" -tech-stack: - added: - - "zod" ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - - // Both phases present - assert.ok(digest.phases['01'], 'Phase 01 should exist'); - assert.ok(digest.phases['02'], 'Phase 02 should exist'); - - // Decisions merged - assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total'); - - // Tech stack merged - assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod'); - }); - - test('malformed SUMMARY.md skipped gracefully', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - - // Valid summary - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -phase: "01" -provides: - - "Valid feature" ---- -` - ); - - // Malformed summary (no frontmatter) - fs.writeFileSync( - path.join(phaseDir, '01-02-SUMMARY.md'), - `# Just a heading -No frontmatter here -` - ); - - // Another malformed summary (broken YAML) - fs.writeFileSync( - path.join(phaseDir, '01-03-SUMMARY.md'), - `--- -broken: [unclosed ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`); - - const digest = JSON.parse(result.output); - assert.ok(digest.phases['01'], 'Phase 01 should exist'); - assert.ok( - digest.phases['01'].provides.includes('Valid feature'), - 'Valid feature should be extracted' - ); - }); - - test('flat provides field still works (backward compatibility)', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -phase: "01" -provides: - - "Direct provides" ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - assert.deepStrictEqual( - digest.phases['01'].provides, - ['Direct provides'], - 'Direct provides should work' - ); - }); - - test('inline array syntax supported', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -phase: "01" -provides: [Feature A, Feature B] -patterns-established: ["Pattern X", "Pattern Y"] ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - assert.deepStrictEqual( - digest.phases['01'].provides.sort(), - ['Feature A', 'Feature B'], - 'Inline array should work' - ); - assert.deepStrictEqual( - digest.phases['01'].patterns.sort(), - ['Pattern X', 'Pattern Y'], - 'Inline quoted array should work' - ); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// phases list command -// ───────────────────────────────────────────────────────────────────────────── - -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('roadmap get-phase command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('extracts phase section from ROADMAP.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -## Phases - -### Phase 1: Foundation -**Goal:** Set up project infrastructure -**Plans:** 2 plans - -Some description here. - -### Phase 2: API -**Goal:** Build REST API -**Plans:** 3 plans -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, true, 'phase should be found'); - assert.strictEqual(output.phase_number, '1', 'phase number correct'); - assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); - assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); - }); - - test('returns not found for missing phase', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -### Phase 1: Foundation -**Goal:** Set up project -` - ); - - const result = runGsdTools('roadmap get-phase 5', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, false, 'phase should not be found'); - }); - - test('handles decimal phase numbers', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap - -### Phase 2: Main -**Goal:** Main work - -### Phase 2.1: Hotfix -**Goal:** Emergency fix -` - ); - - const result = runGsdTools('roadmap get-phase 2.1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, true, 'decimal phase should be found'); - assert.strictEqual(output.phase_name, 'Hotfix', 'phase name correct'); - assert.strictEqual(output.goal, 'Emergency fix', 'goal extracted'); - }); - - test('extracts full section content', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap - -### Phase 1: Setup -**Goal:** Initialize everything - -This phase covers: -- Database setup -- Auth configuration -- CI/CD pipeline - -### Phase 2: Build -**Goal:** Build features -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok(output.section.includes('Database setup'), 'section includes description'); - assert.ok(output.section.includes('CI/CD pipeline'), 'section includes all bullets'); - assert.ok(!output.section.includes('Phase 2'), 'section does not include next phase'); - }); - - test('handles missing ROADMAP.md gracefully', () => { - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, false, 'should return not found'); - assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why'); - }); - - test('accepts ## phase headers (two hashes)', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -## Phase 1: Foundation -**Goal:** Set up project infrastructure -**Plans:** 2 plans - -## Phase 2: API -**Goal:** Build REST API -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, true, 'phase with ## header should be found'); - assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); - assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); - }); - - test('detects malformed ROADMAP with summary list but no detail sections', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -## Phases - -- [ ] **Phase 1: Foundation** - Set up project -- [ ] **Phase 2: API** - Build REST API -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, false, 'phase should not be found'); - assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap'); - assert.ok(output.message.includes('missing'), 'should explain the issue'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// phase next-decimal 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'); - }); - - 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('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'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// state-snapshot command -// ───────────────────────────────────────────────────────────────────────────── - -describe('state-snapshot command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('missing STATE.md returns error', () => { - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file'); - }); - - test('extracts basic fields from STATE.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 -**Current Phase Name:** API Layer -**Total Phases:** 6 -**Current Plan:** 03-02 -**Total Plans in Phase:** 3 -**Status:** In progress -**Progress:** 45% -**Last Activity:** 2024-01-15 -**Last Activity Description:** Completed 03-01-PLAN.md -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.current_phase, '03', 'current phase extracted'); - assert.strictEqual(output.current_phase_name, 'API Layer', 'phase name extracted'); - assert.strictEqual(output.total_phases, 6, 'total phases extracted'); - assert.strictEqual(output.current_plan, '03-02', 'current plan extracted'); - assert.strictEqual(output.total_plans_in_phase, 3, 'total plans extracted'); - assert.strictEqual(output.status, 'In progress', 'status extracted'); - assert.strictEqual(output.progress_percent, 45, 'progress extracted'); - assert.strictEqual(output.last_activity, '2024-01-15', 'last activity date extracted'); - }); - - test('extracts decisions table', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 01 - -## Decisions Made - -| Phase | Decision | Rationale | -|-------|----------|-----------| -| 01 | Use Prisma | Better DX than raw SQL | -| 02 | JWT auth | Stateless authentication | -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.decisions.length, 2, 'should have 2 decisions'); - assert.strictEqual(output.decisions[0].phase, '01', 'first decision phase'); - assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'first decision summary'); - assert.strictEqual(output.decisions[0].rationale, 'Better DX than raw SQL', 'first decision rationale'); - }); - - test('extracts blockers list', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 - -## Blockers - -- Waiting for API credentials -- Need design review for dashboard -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.deepStrictEqual(output.blockers, [ - 'Waiting for API credentials', - 'Need design review for dashboard', - ], 'blockers extracted'); - }); - - test('extracts session continuity info', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 - -## Session - -**Last Date:** 2024-01-15 -**Stopped At:** Phase 3, Plan 2, Task 1 -**Resume File:** .planning/phases/03-api/03-02-PLAN.md -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.session.last_date, '2024-01-15', 'session date extracted'); - assert.strictEqual(output.session.stopped_at, 'Phase 3, Plan 2, Task 1', 'stopped at extracted'); - assert.strictEqual(output.session.resume_file, '.planning/phases/03-api/03-02-PLAN.md', 'resume file extracted'); - }); - - test('handles paused_at field', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 -**Paused At:** Phase 3, Plan 1, Task 2 - mid-implementation -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.paused_at, 'Phase 3, Plan 1, Task 2 - mid-implementation', 'paused_at extracted'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// summary-extract command -// ───────────────────────────────────────────────────────────────────────────── - -describe('summary-extract command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('missing file returns error', () => { - const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.error, 'File not found', 'should report missing file'); - }); - - test('extracts all fields from SUMMARY.md', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -one-liner: Set up Prisma with User and Project models -key-files: - - prisma/schema.prisma - - src/lib/db.ts -tech-stack: - added: - - prisma - - zod -patterns-established: - - Repository pattern - - Dependency injection -key-decisions: - - Use Prisma over Drizzle: Better DX and ecosystem - - Single database: Start simple, shard later ---- - -# Summary - -Full summary content here. -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct'); - assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted'); - assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted'); - assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted'); - assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted'); - assert.strictEqual(output.decisions.length, 2, 'decisions extracted'); - }); - - test('selective extraction with --fields', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -one-liner: Set up database -key-files: - - prisma/schema.prisma -tech-stack: - added: - - prisma -patterns-established: - - Repository pattern -key-decisions: - - Use Prisma: Better DX ---- -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included'); - assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included'); - assert.strictEqual(output.tech_added, undefined, 'tech_added excluded'); - assert.strictEqual(output.patterns, undefined, 'patterns excluded'); - assert.strictEqual(output.decisions, undefined, 'decisions excluded'); - }); - - test('handles missing frontmatter fields gracefully', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -one-liner: Minimal summary ---- - -# Summary -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted'); - assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty'); - assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty'); - assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty'); - assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty'); - }); - - test('parses key-decisions with rationale', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -key-decisions: - - Use Prisma: Better DX than alternatives - - JWT tokens: Stateless auth for scalability ---- -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed'); - assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed'); - assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary'); - assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// init commands tests -// ───────────────────────────────────────────────────────────────────────────── - -describe('init commands', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('init execute-phase returns file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); - - const result = runGsdTools('init execute-phase 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.config_path, '.planning/config.json'); - }); - - test('init plan-phase returns file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); - fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research Findings'); - fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); - fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); - - const result = runGsdTools('init plan-phase 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); - assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); - assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); - assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); - assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); - }); - - test('init progress returns file paths', () => { - const result = runGsdTools('init progress', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.project_path, '.planning/PROJECT.md'); - assert.strictEqual(output.config_path, '.planning/config.json'); - }); - - test('init phase-op returns core and optional phase file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); - fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research'); - fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); - fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); - - const result = runGsdTools('init phase-op 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); - assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); - assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); - assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); - assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); - }); - - test('init plan-phase omits optional paths if files missing', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - - const result = runGsdTools('init plan-phase 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.context_path, undefined); - assert.strictEqual(output.research_path, undefined); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// roadmap analyze command -// ───────────────────────────────────────────────────────────────────────────── - -describe('roadmap analyze command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('missing ROADMAP.md returns error', () => { - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `Command should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.error, 'ROADMAP.md not found'); - }); - - test('parses phases with goals and disk status', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -### Phase 1: Foundation -**Goal:** Set up infrastructure - -### Phase 2: Authentication -**Goal:** Add user auth - -### Phase 3: Features -**Goal:** Build core features -` - ); - - // Create phase dirs with varying completion - 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 p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication'); - fs.mkdirSync(p2, { recursive: true }); - fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); - - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase_count, 3, 'should find 3 phases'); - assert.strictEqual(output.phases[0].disk_status, 'complete', 'phase 1 complete'); - assert.strictEqual(output.phases[1].disk_status, 'planned', 'phase 2 planned'); - assert.strictEqual(output.phases[2].disk_status, 'no_directory', 'phase 3 no directory'); - assert.strictEqual(output.completed_phases, 1, '1 phase complete'); - assert.strictEqual(output.total_plans, 2, '2 total plans'); - assert.strictEqual(output.total_summaries, 1, '1 total summary'); - assert.strictEqual(output.progress_percent, 50, '50% complete'); - assert.strictEqual(output.current_phase, '2', 'current phase is 2'); - }); - - test('extracts goals and dependencies', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap - -### Phase 1: Setup -**Goal:** Initialize project -**Depends on:** Nothing - -### Phase 2: Build -**Goal:** Build features -**Depends on:** Phase 1 -` - ); - - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phases[0].goal, 'Initialize project'); - assert.strictEqual(output.phases[0].depends_on, 'Nothing'); - assert.strictEqual(output.phases[1].goal, 'Build features'); - assert.strictEqual(output.phases[1].depends_on, 'Phase 1'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// phase add 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'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 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('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'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 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}`); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// milestone complete command -// ───────────────────────────────────────────────────────────────────────────── - -describe('milestone complete command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('archives roadmap, requirements, creates MILESTONES.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), - `# Requirements\n\n- [ ] User auth\n- [ ] Dashboard\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\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-SUMMARY.md'), - `---\none-liner: Set up project infrastructure\n---\n# Summary\n` - ); - - const result = runGsdTools('milestone complete v1.0 --name MVP Foundation', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.version, 'v1.0'); - assert.strictEqual(output.phases, 1); - assert.ok(output.archived.roadmap, 'roadmap should be archived'); - assert.ok(output.archived.requirements, 'requirements should be archived'); - - // Verify archive files exist - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md')), - 'archived roadmap should exist' - ); - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md')), - 'archived requirements should exist' - ); - - // Verify MILESTONES.md created - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')), - 'MILESTONES.md should be created' - ); - const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); - assert.ok(milestones.includes('v1.0 MVP Foundation'), 'milestone entry should contain name'); - assert.ok(milestones.includes('Set up project infrastructure'), 'accomplishments should be listed'); - }); - - test('appends to existing MILESTONES.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'MILESTONES.md'), - `# Milestones\n\n## v0.9 Alpha (Shipped: 2025-01-01)\n\n---\n\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - - const result = runGsdTools('milestone complete v1.0 --name Beta', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); - assert.ok(milestones.includes('v0.9 Alpha'), 'existing entry should be preserved'); - assert.ok(milestones.includes('v1.0 Beta'), 'new entry should be appended'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// validate consistency command -// ───────────────────────────────────────────────────────────────────────────── - -describe('validate consistency command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('passes for consistent project', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n### Phase 1: A\n### Phase 2: B\n### Phase 3: C\n` - ); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.passed, true, 'should pass'); - assert.strictEqual(output.warning_count, 0, 'no warnings'); - }); - - test('warns about phase on disk but not in roadmap', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n### Phase 1: A\n` - ); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-orphan'), { recursive: true }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok(output.warning_count > 0, 'should have warnings'); - assert.ok( - output.warnings.some(w => w.includes('disk but not in ROADMAP')), - 'should warn about orphan directory' - ); - }); - - test('warns about gaps in phase numbering', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n### Phase 1: A\n### Phase 3: C\n` - ); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok( - output.warnings.some(w => w.includes('Gap in phase numbering')), - 'should warn about gap' - ); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// progress command -// ───────────────────────────────────────────────────────────────────────────── - -describe('progress command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('renders JSON progress', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 MVP\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'), '# Done'); - fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); - - const result = runGsdTools('progress json', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.total_plans, 2, '2 total plans'); - assert.strictEqual(output.total_summaries, 1, '1 summary'); - assert.strictEqual(output.percent, 50, '50%'); - assert.strictEqual(output.phases.length, 1, '1 phase'); - assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress'); - }); - - test('renders bar format', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - 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'), '# Done'); - - const result = runGsdTools('progress bar --raw', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - assert.ok(result.output.includes('1/1'), 'should include count'); - assert.ok(result.output.includes('100%'), 'should include 100%'); - }); - - test('renders table format', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 MVP\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'); - - const result = runGsdTools('progress table --raw', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - assert.ok(result.output.includes('Phase'), 'should have table header'); - assert.ok(result.output.includes('foundation'), 'should include phase name'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// todo complete command -// ───────────────────────────────────────────────────────────────────────────── - -describe('todo complete command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('moves todo from pending to completed', () => { - const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); - fs.mkdirSync(pendingDir, { recursive: true }); - fs.writeFileSync( - path.join(pendingDir, 'add-dark-mode.md'), - `title: Add dark mode\narea: ui\ncreated: 2025-01-01\n` - ); - - const result = runGsdTools('todo complete add-dark-mode.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.completed, true); - - // Verify moved - assert.ok( - !fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')), - 'should be removed from pending' - ); - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')), - 'should be in completed' - ); - - // Verify completion timestamp added - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'), - 'utf-8' - ); - assert.ok(content.startsWith('completed:'), 'should have completed timestamp'); - }); - - test('fails for nonexistent todo', () => { - const result = runGsdTools('todo complete nonexistent.md', tmpDir); - assert.ok(!result.success, 'should fail'); - assert.ok(result.error.includes('not found'), 'error mentions not found'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// scaffold command -// ───────────────────────────────────────────────────────────────────────────── - -describe('scaffold command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('scaffolds context file', () => { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); - - const result = runGsdTools('scaffold context --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - - // Verify file content - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'), - 'utf-8' - ); - assert.ok(content.includes('Phase 3'), 'should reference phase number'); - assert.ok(content.includes('Decisions'), 'should have decisions section'); - assert.ok(content.includes('Discretion Areas'), 'should have discretion section'); - }); - - test('scaffolds UAT file', () => { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); - - const result = runGsdTools('scaffold uat --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'), - 'utf-8' - ); - assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading'); - assert.ok(content.includes('Test Results'), 'should have test results section'); - }); - - test('scaffolds verification file', () => { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); - - const result = runGsdTools('scaffold verification --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'), - 'utf-8' - ); - assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading'); - }); - - test('scaffolds phase directory', () => { - const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')), - 'directory should be created' - ); - }); - - test('does not overwrite existing files', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content'); - - const result = runGsdTools('scaffold context --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, false, 'should not overwrite'); - assert.strictEqual(output.reason, 'already_exists'); - }); -}); diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs new file mode 100644 index 000000000..a6d776d8e --- /dev/null +++ b/tests/commands.test.cjs @@ -0,0 +1,625 @@ +/** + * GSD Tools Tests - Commands + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('history-digest command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('empty phases directory returns valid schema', () => { + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + + assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object'); + assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array'); + assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array'); + }); + + test('nested frontmatter fields extracted correctly', () => { + // Create phase directory with SUMMARY containing nested frontmatter + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + const summaryContent = `--- +phase: "01" +name: "Foundation Setup" +dependency-graph: + provides: + - "Database schema" + - "Auth system" + affects: + - "API layer" +tech-stack: + added: + - "prisma" + - "jose" +patterns-established: + - "Repository pattern" + - "JWT auth flow" +key-decisions: + - "Use Prisma over Drizzle" + - "JWT in httpOnly cookies" +--- + +# Summary content here +`; + + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + + // Check nested dependency-graph.provides + assert.ok(digest.phases['01'], 'Phase 01 should exist'); + assert.deepStrictEqual( + digest.phases['01'].provides.sort(), + ['Auth system', 'Database schema'], + 'provides should contain nested values' + ); + + // Check nested dependency-graph.affects + assert.deepStrictEqual( + digest.phases['01'].affects, + ['API layer'], + 'affects should contain nested values' + ); + + // Check nested tech-stack.added + assert.deepStrictEqual( + digest.tech_stack.sort(), + ['jose', 'prisma'], + 'tech_stack should contain nested values' + ); + + // Check patterns-established (flat array) + assert.deepStrictEqual( + digest.phases['01'].patterns.sort(), + ['JWT auth flow', 'Repository pattern'], + 'patterns should be extracted' + ); + + // Check key-decisions + assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions'); + assert.ok( + digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'), + 'Should contain first decision' + ); + }); + + test('multiple phases merged into single digest', () => { + // Create phase 01 + const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.writeFileSync( + path.join(phase01Dir, '01-01-SUMMARY.md'), + `--- +phase: "01" +name: "Foundation" +provides: + - "Database" +patterns-established: + - "Pattern A" +key-decisions: + - "Decision 1" +--- +` + ); + + // Create phase 02 + const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api'); + fs.mkdirSync(phase02Dir, { recursive: true }); + fs.writeFileSync( + path.join(phase02Dir, '02-01-SUMMARY.md'), + `--- +phase: "02" +name: "API" +provides: + - "REST endpoints" +patterns-established: + - "Pattern B" +key-decisions: + - "Decision 2" +tech-stack: + added: + - "zod" +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + + // Both phases present + assert.ok(digest.phases['01'], 'Phase 01 should exist'); + assert.ok(digest.phases['02'], 'Phase 02 should exist'); + + // Decisions merged + assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total'); + + // Tech stack merged + assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod'); + }); + + test('malformed SUMMARY.md skipped gracefully', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + + // Valid summary + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +phase: "01" +provides: + - "Valid feature" +--- +` + ); + + // Malformed summary (no frontmatter) + fs.writeFileSync( + path.join(phaseDir, '01-02-SUMMARY.md'), + `# Just a heading +No frontmatter here +` + ); + + // Another malformed summary (broken YAML) + fs.writeFileSync( + path.join(phaseDir, '01-03-SUMMARY.md'), + `--- +broken: [unclosed +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`); + + const digest = JSON.parse(result.output); + assert.ok(digest.phases['01'], 'Phase 01 should exist'); + assert.ok( + digest.phases['01'].provides.includes('Valid feature'), + 'Valid feature should be extracted' + ); + }); + + test('flat provides field still works (backward compatibility)', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +phase: "01" +provides: + - "Direct provides" +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + assert.deepStrictEqual( + digest.phases['01'].provides, + ['Direct provides'], + 'Direct provides should work' + ); + }); + + test('inline array syntax supported', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +phase: "01" +provides: [Feature A, Feature B] +patterns-established: ["Pattern X", "Pattern Y"] +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + assert.deepStrictEqual( + digest.phases['01'].provides.sort(), + ['Feature A', 'Feature B'], + 'Inline array should work' + ); + assert.deepStrictEqual( + digest.phases['01'].patterns.sort(), + ['Pattern X', 'Pattern Y'], + 'Inline quoted array should work' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// phases list command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('summary-extract command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing file returns error', () => { + const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.error, 'File not found', 'should report missing file'); + }); + + test('extracts all fields from SUMMARY.md', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Set up Prisma with User and Project models +key-files: + - prisma/schema.prisma + - src/lib/db.ts +tech-stack: + added: + - prisma + - zod +patterns-established: + - Repository pattern + - Dependency injection +key-decisions: + - Use Prisma over Drizzle: Better DX and ecosystem + - Single database: Start simple, shard later +--- + +# Summary + +Full summary content here. +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct'); + assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted'); + assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted'); + assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted'); + assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted'); + assert.strictEqual(output.decisions.length, 2, 'decisions extracted'); + }); + + test('selective extraction with --fields', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Set up database +key-files: + - prisma/schema.prisma +tech-stack: + added: + - prisma +patterns-established: + - Repository pattern +key-decisions: + - Use Prisma: Better DX +--- +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included'); + assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included'); + assert.strictEqual(output.tech_added, undefined, 'tech_added excluded'); + assert.strictEqual(output.patterns, undefined, 'patterns excluded'); + assert.strictEqual(output.decisions, undefined, 'decisions excluded'); + }); + + test('handles missing frontmatter fields gracefully', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Minimal summary +--- + +# Summary +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted'); + assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty'); + assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty'); + assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty'); + assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty'); + }); + + test('parses key-decisions with rationale', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +key-decisions: + - Use Prisma: Better DX than alternatives + - JWT tokens: Stateless auth for scalability +--- +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed'); + assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed'); + assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary'); + assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// init commands tests +// ───────────────────────────────────────────────────────────────────────────── + + +describe('progress command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('renders JSON progress', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 MVP\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'), '# Done'); + fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); + + const result = runGsdTools('progress json', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.total_plans, 2, '2 total plans'); + assert.strictEqual(output.total_summaries, 1, '1 summary'); + assert.strictEqual(output.percent, 50, '50%'); + assert.strictEqual(output.phases.length, 1, '1 phase'); + assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress'); + }); + + test('renders bar format', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n` + ); + 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'), '# Done'); + + const result = runGsdTools('progress bar --raw', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.output.includes('1/1'), 'should include count'); + assert.ok(result.output.includes('100%'), 'should include 100%'); + }); + + test('renders table format', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 MVP\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'); + + const result = runGsdTools('progress table --raw', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.output.includes('Phase'), 'should have table header'); + assert.ok(result.output.includes('foundation'), 'should include phase name'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// todo complete command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('todo complete command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('moves todo from pending to completed', () => { + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync( + path.join(pendingDir, 'add-dark-mode.md'), + `title: Add dark mode\narea: ui\ncreated: 2025-01-01\n` + ); + + const result = runGsdTools('todo complete add-dark-mode.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.completed, true); + + // Verify moved + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')), + 'should be removed from pending' + ); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')), + 'should be in completed' + ); + + // Verify completion timestamp added + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'), + 'utf-8' + ); + assert.ok(content.startsWith('completed:'), 'should have completed timestamp'); + }); + + test('fails for nonexistent todo', () => { + const result = runGsdTools('todo complete nonexistent.md', tmpDir); + assert.ok(!result.success, 'should fail'); + assert.ok(result.error.includes('not found'), 'error mentions not found'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// scaffold command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('scaffold command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('scaffolds context file', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); + + const result = runGsdTools('scaffold context --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + + // Verify file content + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'), + 'utf-8' + ); + assert.ok(content.includes('Phase 3'), 'should reference phase number'); + assert.ok(content.includes('Decisions'), 'should have decisions section'); + assert.ok(content.includes('Discretion Areas'), 'should have discretion section'); + }); + + test('scaffolds UAT file', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); + + const result = runGsdTools('scaffold uat --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'), + 'utf-8' + ); + assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading'); + assert.ok(content.includes('Test Results'), 'should have test results section'); + }); + + test('scaffolds verification file', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); + + const result = runGsdTools('scaffold verification --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'), + 'utf-8' + ); + assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading'); + }); + + test('scaffolds phase directory', () => { + const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')), + 'directory should be created' + ); + }); + + test('does not overwrite existing files', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content'); + + const result = runGsdTools('scaffold context --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, false, 'should not overwrite'); + assert.strictEqual(output.reason, 'already_exists'); + }); +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs new file mode 100644 index 000000000..76a809b95 --- /dev/null +++ b/tests/helpers.cjs @@ -0,0 +1,40 @@ +/** + * GSD Tools Test Helpers + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + +// Helper to run gsd-tools command +function runGsdTools(args, cwd = process.cwd()) { + try { + const result = execSync(`node "${TOOLS_PATH}" ${args}`, { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true, output: result.trim() }; + } catch (err) { + return { + success: false, + output: err.stdout?.toString().trim() || '', + error: err.stderr?.toString().trim() || err.message, + }; + } +} + +// Create temp directory structure +function createTempProject() { + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-test-')); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + return tmpDir; +} + +function cleanup(tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); +} + +module.exports = { runGsdTools, createTempProject, cleanup, TOOLS_PATH }; diff --git a/tests/init.test.cjs b/tests/init.test.cjs new file mode 100644 index 000000000..ffbd860d6 --- /dev/null +++ b/tests/init.test.cjs @@ -0,0 +1,105 @@ +/** + * GSD Tools Tests - Init + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('init commands', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('init execute-phase returns file paths', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); + + const result = runGsdTools('init execute-phase 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.config_path, '.planning/config.json'); + }); + + test('init plan-phase returns file paths', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); + fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research Findings'); + fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); + fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); + + const result = runGsdTools('init plan-phase 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); + assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); + assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); + assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); + assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); + }); + + test('init progress returns file paths', () => { + const result = runGsdTools('init progress', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.project_path, '.planning/PROJECT.md'); + assert.strictEqual(output.config_path, '.planning/config.json'); + }); + + test('init phase-op returns core and optional phase file paths', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); + fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research'); + fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); + fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); + + const result = runGsdTools('init phase-op 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); + assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); + assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); + assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); + assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); + }); + + test('init plan-phase omits optional paths if files missing', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + + const result = runGsdTools('init plan-phase 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.context_path, undefined); + assert.strictEqual(output.research_path, undefined); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// roadmap analyze command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/milestone.test.cjs b/tests/milestone.test.cjs new file mode 100644 index 000000000..bb03c87bf --- /dev/null +++ b/tests/milestone.test.cjs @@ -0,0 +1,98 @@ +/** + * GSD Tools Tests - Milestone + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('milestone complete command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('archives roadmap, requirements, creates MILESTONES.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), + `# Requirements\n\n- [ ] User auth\n- [ ] Dashboard\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\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-SUMMARY.md'), + `---\none-liner: Set up project infrastructure\n---\n# Summary\n` + ); + + const result = runGsdTools('milestone complete v1.0 --name MVP Foundation', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.version, 'v1.0'); + assert.strictEqual(output.phases, 1); + assert.ok(output.archived.roadmap, 'roadmap should be archived'); + assert.ok(output.archived.requirements, 'requirements should be archived'); + + // Verify archive files exist + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md')), + 'archived roadmap should exist' + ); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md')), + 'archived requirements should exist' + ); + + // Verify MILESTONES.md created + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')), + 'MILESTONES.md should be created' + ); + const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); + assert.ok(milestones.includes('v1.0 MVP Foundation'), 'milestone entry should contain name'); + assert.ok(milestones.includes('Set up project infrastructure'), 'accomplishments should be listed'); + }); + + test('appends to existing MILESTONES.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'MILESTONES.md'), + `# Milestones\n\n## v0.9 Alpha (Shipped: 2025-01-01)\n\n---\n\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + const result = runGsdTools('milestone complete v1.0 --name Beta', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); + assert.ok(milestones.includes('v0.9 Alpha'), 'existing entry should be preserved'); + assert.ok(milestones.includes('v1.0 Beta'), 'new entry should be appended'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// validate consistency command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs new file mode 100644 index 000000000..86380d705 --- /dev/null +++ b/tests/phase.test.cjs @@ -0,0 +1,1013 @@ +/** + * GSD Tools Tests - Phase + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +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'); + }); + + 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('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'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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('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'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// milestone complete command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs new file mode 100644 index 000000000..8e3304734 --- /dev/null +++ b/tests/roadmap.test.cjs @@ -0,0 +1,265 @@ +/** + * GSD Tools Tests - Roadmap + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('roadmap get-phase command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('extracts phase section from ROADMAP.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +## Phases + +### Phase 1: Foundation +**Goal:** Set up project infrastructure +**Plans:** 2 plans + +Some description here. + +### Phase 2: API +**Goal:** Build REST API +**Plans:** 3 plans +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'phase should be found'); + assert.strictEqual(output.phase_number, '1', 'phase number correct'); + assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); + assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); + }); + + test('returns not found for missing phase', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +### Phase 1: Foundation +**Goal:** Set up project +` + ); + + const result = runGsdTools('roadmap get-phase 5', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, false, 'phase should not be found'); + }); + + test('handles decimal phase numbers', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 2: Main +**Goal:** Main work + +### Phase 2.1: Hotfix +**Goal:** Emergency fix +` + ); + + const result = runGsdTools('roadmap get-phase 2.1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'decimal phase should be found'); + assert.strictEqual(output.phase_name, 'Hotfix', 'phase name correct'); + assert.strictEqual(output.goal, 'Emergency fix', 'goal extracted'); + }); + + test('extracts full section content', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 1: Setup +**Goal:** Initialize everything + +This phase covers: +- Database setup +- Auth configuration +- CI/CD pipeline + +### Phase 2: Build +**Goal:** Build features +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.section.includes('Database setup'), 'section includes description'); + assert.ok(output.section.includes('CI/CD pipeline'), 'section includes all bullets'); + assert.ok(!output.section.includes('Phase 2'), 'section does not include next phase'); + }); + + test('handles missing ROADMAP.md gracefully', () => { + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, false, 'should return not found'); + assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why'); + }); + + test('accepts ## phase headers (two hashes)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +## Phase 1: Foundation +**Goal:** Set up project infrastructure +**Plans:** 2 plans + +## Phase 2: API +**Goal:** Build REST API +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'phase with ## header should be found'); + assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); + assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); + }); + + test('detects malformed ROADMAP with summary list but no detail sections', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +## Phases + +- [ ] **Phase 1: Foundation** - Set up project +- [ ] **Phase 2: API** - Build REST API +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, false, 'phase should not be found'); + assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap'); + assert.ok(output.message.includes('missing'), 'should explain the issue'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// phase next-decimal command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('roadmap analyze command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing ROADMAP.md returns error', () => { + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.error, 'ROADMAP.md not found'); + }); + + test('parses phases with goals and disk status', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +### Phase 1: Foundation +**Goal:** Set up infrastructure + +### Phase 2: Authentication +**Goal:** Add user auth + +### Phase 3: Features +**Goal:** Build core features +` + ); + + // Create phase dirs with varying completion + 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 p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication'); + fs.mkdirSync(p2, { recursive: true }); + fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_count, 3, 'should find 3 phases'); + assert.strictEqual(output.phases[0].disk_status, 'complete', 'phase 1 complete'); + assert.strictEqual(output.phases[1].disk_status, 'planned', 'phase 2 planned'); + assert.strictEqual(output.phases[2].disk_status, 'no_directory', 'phase 3 no directory'); + assert.strictEqual(output.completed_phases, 1, '1 phase complete'); + assert.strictEqual(output.total_plans, 2, '2 total plans'); + assert.strictEqual(output.total_summaries, 1, '1 total summary'); + assert.strictEqual(output.progress_percent, 50, '50% complete'); + assert.strictEqual(output.current_phase, '2', 'current phase is 2'); + }); + + test('extracts goals and dependencies', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 1: Setup +**Goal:** Initialize project +**Depends on:** Nothing + +### Phase 2: Build +**Goal:** Build features +**Depends on:** Phase 1 +` + ); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].goal, 'Initialize project'); + assert.strictEqual(output.phases[0].depends_on, 'Nothing'); + assert.strictEqual(output.phases[1].goal, 'Build features'); + assert.strictEqual(output.phases[1].depends_on, 'Phase 1'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// phase add command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/state.test.cjs b/tests/state.test.cjs new file mode 100644 index 000000000..6b22460ca --- /dev/null +++ b/tests/state.test.cjs @@ -0,0 +1,156 @@ +/** + * GSD Tools Tests - State + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('state-snapshot command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing STATE.md returns error', () => { + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file'); + }); + + test('extracts basic fields from STATE.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 +**Current Phase Name:** API Layer +**Total Phases:** 6 +**Current Plan:** 03-02 +**Total Plans in Phase:** 3 +**Status:** In progress +**Progress:** 45% +**Last Activity:** 2024-01-15 +**Last Activity Description:** Completed 03-01-PLAN.md +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.current_phase, '03', 'current phase extracted'); + assert.strictEqual(output.current_phase_name, 'API Layer', 'phase name extracted'); + assert.strictEqual(output.total_phases, 6, 'total phases extracted'); + assert.strictEqual(output.current_plan, '03-02', 'current plan extracted'); + assert.strictEqual(output.total_plans_in_phase, 3, 'total plans extracted'); + assert.strictEqual(output.status, 'In progress', 'status extracted'); + assert.strictEqual(output.progress_percent, 45, 'progress extracted'); + assert.strictEqual(output.last_activity, '2024-01-15', 'last activity date extracted'); + }); + + test('extracts decisions table', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 01 + +## Decisions Made + +| Phase | Decision | Rationale | +|-------|----------|-----------| +| 01 | Use Prisma | Better DX than raw SQL | +| 02 | JWT auth | Stateless authentication | +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.decisions.length, 2, 'should have 2 decisions'); + assert.strictEqual(output.decisions[0].phase, '01', 'first decision phase'); + assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'first decision summary'); + assert.strictEqual(output.decisions[0].rationale, 'Better DX than raw SQL', 'first decision rationale'); + }); + + test('extracts blockers list', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 + +## Blockers + +- Waiting for API credentials +- Need design review for dashboard +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.deepStrictEqual(output.blockers, [ + 'Waiting for API credentials', + 'Need design review for dashboard', + ], 'blockers extracted'); + }); + + test('extracts session continuity info', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 + +## Session + +**Last Date:** 2024-01-15 +**Stopped At:** Phase 3, Plan 2, Task 1 +**Resume File:** .planning/phases/03-api/03-02-PLAN.md +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.session.last_date, '2024-01-15', 'session date extracted'); + assert.strictEqual(output.session.stopped_at, 'Phase 3, Plan 2, Task 1', 'stopped at extracted'); + assert.strictEqual(output.session.resume_file, '.planning/phases/03-api/03-02-PLAN.md', 'resume file extracted'); + }); + + test('handles paused_at field', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 +**Paused At:** Phase 3, Plan 1, Task 2 - mid-implementation +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.paused_at, 'Phase 3, Plan 1, Task 2 - mid-implementation', 'paused_at extracted'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// summary-extract command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs new file mode 100644 index 000000000..7da2858e1 --- /dev/null +++ b/tests/verify.test.cjs @@ -0,0 +1,80 @@ +/** + * GSD Tools Tests - Verify + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('validate consistency command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('passes for consistent project', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n### Phase 1: A\n### Phase 2: B\n### Phase 3: C\n` + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.passed, true, 'should pass'); + assert.strictEqual(output.warning_count, 0, 'no warnings'); + }); + + test('warns about phase on disk but not in roadmap', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n### Phase 1: A\n` + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-orphan'), { recursive: true }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.warning_count > 0, 'should have warnings'); + assert.ok( + output.warnings.some(w => w.includes('disk but not in ROADMAP')), + 'should warn about orphan directory' + ); + }); + + test('warns about gaps in phase numbering', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n### Phase 1: A\n### Phase 3: C\n` + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.includes('Gap in phase numbering')), + 'should warn about gap' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// progress command +// ───────────────────────────────────────────────────────────────────────────── + From c67ab759a7cdb6e545b94144361f5208c7da7dfd Mon Sep 17 00:00:00 2001 From: Tyler Satre Date: Fri, 20 Feb 2026 10:40:45 -0500 Subject: [PATCH 2/3] refactor: split gsd-tools.cjs into 11 domain modules under bin/lib/ Break the 5324-line monolith into focused modules: - core.cjs: shared utilities, constants, internal helpers - frontmatter.cjs: YAML frontmatter parsing/serialization/CRUD - state.cjs: STATE.md operations + progression engine - phase.cjs: phase CRUD, query, and lifecycle - roadmap.cjs: roadmap parsing and updates - verify.cjs: verification suite + consistency/health validation - config.cjs: config ensure/set/get - template.cjs: template selection and fill - milestone.cjs: milestone + requirements lifecycle - commands.cjs: standalone utility commands - init.cjs: compound init commands for workflow bootstrapping gsd-tools.cjs is now a thin CLI router (~550 lines including JSDoc) that imports from lib/ modules. All 81 tests pass. Also updates package.json test script to point to tests/. Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/gsd-tools.cjs | 4927 +------------------------ get-shit-done/bin/lib/commands.cjs | 556 +++ get-shit-done/bin/lib/config.cjs | 161 + get-shit-done/bin/lib/core.cjs | 377 ++ get-shit-done/bin/lib/frontmatter.cjs | 299 ++ get-shit-done/bin/lib/init.cjs | 693 ++++ get-shit-done/bin/lib/milestone.cjs | 215 ++ get-shit-done/bin/lib/phase.cjs | 877 +++++ get-shit-done/bin/lib/roadmap.cjs | 298 ++ get-shit-done/bin/lib/state.cjs | 490 +++ get-shit-done/bin/lib/template.cjs | 222 ++ get-shit-done/bin/lib/verify.cjs | 772 ++++ package.json | 2 +- 13 files changed, 5039 insertions(+), 4850 deletions(-) create mode 100644 get-shit-done/bin/lib/commands.cjs create mode 100644 get-shit-done/bin/lib/config.cjs create mode 100644 get-shit-done/bin/lib/core.cjs create mode 100644 get-shit-done/bin/lib/frontmatter.cjs create mode 100644 get-shit-done/bin/lib/init.cjs create mode 100644 get-shit-done/bin/lib/milestone.cjs create mode 100644 get-shit-done/bin/lib/phase.cjs create mode 100644 get-shit-done/bin/lib/roadmap.cjs create mode 100644 get-shit-done/bin/lib/state.cjs create mode 100644 get-shit-done/bin/lib/template.cjs create mode 100644 get-shit-done/bin/lib/verify.cjs diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 62d750edf..a83b62928 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -123,4788 +123,17 @@ * init progress All context for progress workflow */ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -// ─── Model Profile Table ───────────────────────────────────────────────────── - -const MODEL_PROFILES = { - 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' }, - 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, - 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, - 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, - 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' }, - 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, -}; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function safeReadFile(filePath) { - try { - return fs.readFileSync(filePath, 'utf-8'); - } catch { - return null; - } -} - -function loadConfig(cwd) { - const configPath = path.join(cwd, '.planning', 'config.json'); - const defaults = { - model_profile: 'balanced', - commit_docs: true, - search_gitignored: false, - branching_strategy: 'none', - phase_branch_template: 'gsd/phase-{phase}-{slug}', - milestone_branch_template: 'gsd/{milestone}-{slug}', - research: true, - plan_checker: true, - verifier: true, - parallelization: true, - brave_search: false, - }; - - try { - const raw = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw); - - const get = (key, nested) => { - if (parsed[key] !== undefined) return parsed[key]; - if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) { - return parsed[nested.section][nested.field]; - } - return undefined; - }; - - const parallelization = (() => { - const val = get('parallelization'); - if (typeof val === 'boolean') return val; - if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled; - return defaults.parallelization; - })(); - - return { - model_profile: get('model_profile') ?? defaults.model_profile, - commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs, - search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored, - branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy, - phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template, - milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template, - research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research, - plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker, - verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier, - parallelization, - brave_search: get('brave_search') ?? defaults.brave_search, - }; - } catch { - return defaults; - } -} - -function isGitIgnored(cwd, targetPath) { - try { - execSync('git check-ignore -q -- ' + targetPath.replace(/[^a-zA-Z0-9._\-/]/g, ''), { - cwd, - stdio: 'pipe', - }); - return true; - } catch { - return false; - } -} - -function execGit(cwd, args) { - try { - const escaped = args.map(a => { - if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a; - return "'" + a.replace(/'/g, "'\\''") + "'"; - }); - const stdout = execSync('git ' + escaped.join(' '), { - cwd, - stdio: 'pipe', - encoding: 'utf-8', - }); - return { exitCode: 0, stdout: stdout.trim(), stderr: '' }; - } catch (err) { - return { - exitCode: err.status ?? 1, - stdout: (err.stdout ?? '').toString().trim(), - stderr: (err.stderr ?? '').toString().trim(), - }; - } -} - -function normalizePhaseName(phase) { - const match = phase.match(/^(\d+(?:\.\d+)?)/); - if (!match) return phase; - const num = match[1]; - const parts = num.split('.'); - const padded = parts[0].padStart(2, '0'); - return parts.length > 1 ? `${padded}.${parts[1]}` : padded; -} - -function extractFrontmatter(content) { - const frontmatter = {}; - const match = content.match(/^---\n([\s\S]+?)\n---/); - if (!match) return frontmatter; - - const yaml = match[1]; - const lines = yaml.split('\n'); - - // Stack to track nested objects: [{obj, key, indent}] - // obj = object to write to, key = current key collecting array items, indent = indentation level - let stack = [{ obj: frontmatter, key: null, indent: -1 }]; - - for (const line of lines) { - // Skip empty lines - if (line.trim() === '') continue; - - // Calculate indentation (number of leading spaces) - const indentMatch = line.match(/^(\s*)/); - const indent = indentMatch ? indentMatch[1].length : 0; - - // Pop stack back to appropriate level - while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { - stack.pop(); - } - - const current = stack[stack.length - 1]; - - // Check for key: value pattern - const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/); - if (keyMatch) { - const key = keyMatch[2]; - const value = keyMatch[3].trim(); - - if (value === '' || value === '[') { - // Key with no value or opening bracket — could be nested object or array - // We'll determine based on next lines, for now create placeholder - current.obj[key] = value === '[' ? [] : {}; - current.key = null; - // Push new context for potential nested content - stack.push({ obj: current.obj[key], key: null, indent }); - } else if (value.startsWith('[') && value.endsWith(']')) { - // Inline array: key: [a, b, c] - current.obj[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean); - current.key = null; - } else { - // Simple key: value - current.obj[key] = value.replace(/^["']|["']$/g, ''); - current.key = null; - } - } else if (line.trim().startsWith('- ')) { - // Array item - const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, ''); - - // If current context is an empty object, convert to array - if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) { - // Find the key in parent that points to this object and convert it - const parent = stack.length > 1 ? stack[stack.length - 2] : null; - if (parent) { - for (const k of Object.keys(parent.obj)) { - if (parent.obj[k] === current.obj) { - parent.obj[k] = [itemValue]; - current.obj = parent.obj[k]; - break; - } - } - } - } else if (Array.isArray(current.obj)) { - current.obj.push(itemValue); - } - } - } - - return frontmatter; -} - -function reconstructFrontmatter(obj) { - const lines = []; - for (const [key, value] of Object.entries(obj)) { - if (value === null || value === undefined) continue; - if (Array.isArray(value)) { - if (value.length === 0) { - lines.push(`${key}: []`); - } else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) { - lines.push(`${key}: [${value.join(', ')}]`); - } else { - lines.push(`${key}:`); - for (const item of value) { - lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`); - } - } - } else if (typeof value === 'object') { - lines.push(`${key}:`); - for (const [subkey, subval] of Object.entries(value)) { - if (subval === null || subval === undefined) continue; - if (Array.isArray(subval)) { - if (subval.length === 0) { - lines.push(` ${subkey}: []`); - } else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) { - lines.push(` ${subkey}: [${subval.join(', ')}]`); - } else { - lines.push(` ${subkey}:`); - for (const item of subval) { - lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`); - } - } - } else if (typeof subval === 'object') { - lines.push(` ${subkey}:`); - for (const [subsubkey, subsubval] of Object.entries(subval)) { - if (subsubval === null || subsubval === undefined) continue; - if (Array.isArray(subsubval)) { - if (subsubval.length === 0) { - lines.push(` ${subsubkey}: []`); - } else { - lines.push(` ${subsubkey}:`); - for (const item of subsubval) { - lines.push(` - ${item}`); - } - } - } else { - lines.push(` ${subsubkey}: ${subsubval}`); - } - } - } else { - const sv = String(subval); - lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`); - } - } - } else { - const sv = String(value); - if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) { - lines.push(`${key}: "${sv}"`); - } else { - lines.push(`${key}: ${sv}`); - } - } - } - return lines.join('\n'); -} - -function spliceFrontmatter(content, newObj) { - const yamlStr = reconstructFrontmatter(newObj); - const match = content.match(/^---\n[\s\S]+?\n---/); - if (match) { - return `---\n${yamlStr}\n---` + content.slice(match[0].length); - } - return `---\n${yamlStr}\n---\n\n` + content; -} - -function parseMustHavesBlock(content, blockName) { - // Extract a specific block from must_haves in raw frontmatter YAML - // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}] - const fmMatch = content.match(/^---\n([\s\S]+?)\n---/); - if (!fmMatch) return []; - - const yaml = fmMatch[1]; - // Find the block (e.g., "truths:", "artifacts:", "key_links:") - const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm'); - const blockStart = yaml.search(blockPattern); - if (blockStart === -1) return []; - - const afterBlock = yaml.slice(blockStart); - const blockLines = afterBlock.split('\n').slice(1); // skip the header line - - const items = []; - let current = null; - - for (const line of blockLines) { - // Stop at same or lower indent level (non-continuation) - if (line.trim() === '') continue; - const indent = line.match(/^(\s*)/)[1].length; - if (indent <= 4 && line.trim() !== '') break; // back to must_haves level or higher - - if (line.match(/^\s{6}-\s+/)) { - // New list item at 6-space indent - if (current) items.push(current); - current = {}; - // Check if it's a simple string item - const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/); - if (simpleMatch && !line.includes(':')) { - current = simpleMatch[1]; - } else { - // Key-value on same line as dash: "- path: value" - const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/); - if (kvMatch) { - current = {}; - current[kvMatch[1]] = kvMatch[2]; - } - } - } else if (current && typeof current === 'object') { - // Continuation key-value at 8+ space indent - const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/); - if (kvMatch) { - const val = kvMatch[2]; - // Try to parse as number - current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; - } - // Array items under a key - const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/); - if (arrMatch) { - // Find the last key added and convert to array - const keys = Object.keys(current); - const lastKey = keys[keys.length - 1]; - if (lastKey && !Array.isArray(current[lastKey])) { - current[lastKey] = current[lastKey] ? [current[lastKey]] : []; - } - if (lastKey) current[lastKey].push(arrMatch[1]); - } - } - } - if (current) items.push(current); - - return items; -} - -function output(result, raw, rawValue) { - if (raw && rawValue !== undefined) { - process.stdout.write(String(rawValue)); - } else { - const json = JSON.stringify(result, null, 2); - // Large payloads exceed Claude Code's Bash tool buffer (~50KB). - // Write to tmpfile and output the path prefixed with @file: so callers can detect it. - if (json.length > 50000) { - const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`); - fs.writeFileSync(tmpPath, json, 'utf-8'); - process.stdout.write('@file:' + tmpPath); - } else { - process.stdout.write(json); - } - } - process.exit(0); -} - -function error(message) { - process.stderr.write('Error: ' + message + '\n'); - process.exit(1); -} - -// ─── Commands ───────────────────────────────────────────────────────────────── - -function cmdGenerateSlug(text, raw) { - if (!text) { - error('text required for slug generation'); - } - - const slug = text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - - const result = { slug }; - output(result, raw, slug); -} - -function cmdCurrentTimestamp(format, raw) { - const now = new Date(); - let result; - - switch (format) { - case 'date': - result = now.toISOString().split('T')[0]; - break; - case 'filename': - result = now.toISOString().replace(/:/g, '-').replace(/\..+/, ''); - break; - case 'full': - default: - result = now.toISOString(); - break; - } - - output({ timestamp: result }, raw, result); -} - -function cmdListTodos(cwd, area, raw) { - const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); - - let count = 0; - const todos = []; - - try { - const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md')); - - for (const file of files) { - try { - const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8'); - const createdMatch = content.match(/^created:\s*(.+)$/m); - const titleMatch = content.match(/^title:\s*(.+)$/m); - const areaMatch = content.match(/^area:\s*(.+)$/m); - - const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; - - // Apply area filter if specified - if (area && todoArea !== area) continue; - - count++; - todos.push({ - file, - created: createdMatch ? createdMatch[1].trim() : 'unknown', - title: titleMatch ? titleMatch[1].trim() : 'Untitled', - area: todoArea, - path: path.join('.planning', 'todos', 'pending', file), - }); - } catch {} - } - } catch {} - - const result = { count, todos }; - output(result, raw, count.toString()); -} - -function cmdVerifyPathExists(cwd, targetPath, raw) { - if (!targetPath) { - error('path required for verification'); - } - - const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); - - try { - const stats = fs.statSync(fullPath); - const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other'; - const result = { exists: true, type }; - output(result, raw, 'true'); - } catch { - const result = { exists: false, type: null }; - output(result, raw, 'false'); - } -} - -function cmdConfigEnsureSection(cwd, raw) { - const configPath = path.join(cwd, '.planning', 'config.json'); - const planningDir = path.join(cwd, '.planning'); - - // Ensure .planning directory exists - try { - if (!fs.existsSync(planningDir)) { - fs.mkdirSync(planningDir, { recursive: true }); - } - } catch (err) { - error('Failed to create .planning directory: ' + err.message); - } - - // Check if config already exists - if (fs.existsSync(configPath)) { - const result = { created: false, reason: 'already_exists' }; - output(result, raw, 'exists'); - return; - } - - // Detect Brave Search API key availability - const homedir = require('os').homedir(); - const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); - const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); - - // Load user-level defaults from ~/.gsd/defaults.json if available - const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json'); - let userDefaults = {}; - try { - if (fs.existsSync(globalDefaultsPath)) { - userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')); - } - } catch (err) { - // Ignore malformed global defaults, fall back to hardcoded - } - - // Create default config (user-level defaults override hardcoded defaults) - const hardcoded = { - model_profile: 'balanced', - commit_docs: true, - search_gitignored: false, - branching_strategy: 'none', - phase_branch_template: 'gsd/phase-{phase}-{slug}', - milestone_branch_template: 'gsd/{milestone}-{slug}', - workflow: { - research: true, - plan_check: true, - verifier: true, - }, - parallelization: true, - brave_search: hasBraveSearch, - }; - const defaults = { - ...hardcoded, - ...userDefaults, - workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}) }, - }; - - try { - fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); - const result = { created: true, path: '.planning/config.json' }; - output(result, raw, 'created'); - } catch (err) { - error('Failed to create config.json: ' + err.message); - } -} - -function cmdConfigSet(cwd, keyPath, value, raw) { - const configPath = path.join(cwd, '.planning', 'config.json'); - - if (!keyPath) { - error('Usage: config-set '); - } - - // Parse value (handle booleans and numbers) - let parsedValue = value; - if (value === 'true') parsedValue = true; - else if (value === 'false') parsedValue = false; - else if (!isNaN(value) && value !== '') parsedValue = Number(value); - - // Load existing config or start with empty object - let config = {}; - try { - if (fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - } - } catch (err) { - error('Failed to read config.json: ' + err.message); - } - - // Set nested value using dot notation (e.g., "workflow.research") - const keys = keyPath.split('.'); - let current = config; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (current[key] === undefined || typeof current[key] !== 'object') { - current[key] = {}; - } - current = current[key]; - } - current[keys[keys.length - 1]] = parsedValue; - - // Write back - try { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); - const result = { updated: true, key: keyPath, value: parsedValue }; - output(result, raw, `${keyPath}=${parsedValue}`); - } catch (err) { - error('Failed to write config.json: ' + err.message); - } -} - -function cmdConfigGet(cwd, keyPath, raw) { - const configPath = path.join(cwd, '.planning', 'config.json'); - - if (!keyPath) { - error('Usage: config-get '); - } - - let config = {}; - try { - if (fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - } else { - error('No config.json found at ' + configPath); - } - } catch (err) { - if (err.message.startsWith('No config.json')) throw err; - error('Failed to read config.json: ' + err.message); - } - - // Traverse dot-notation path (e.g., "workflow.auto_advance") - const keys = keyPath.split('.'); - let current = config; - for (const key of keys) { - if (current === undefined || current === null || typeof current !== 'object') { - error(`Key not found: ${keyPath}`); - } - current = current[key]; - } - - if (current === undefined) { - error(`Key not found: ${keyPath}`); - } - - output(current, raw, String(current)); -} - -function cmdHistoryDigest(cwd, raw) { - const phasesDir = path.join(cwd, '.planning', 'phases'); - const digest = { phases: {}, decisions: [], tech_stack: new Set() }; - - // Collect all phase directories: archived + current - const allPhaseDirs = []; - - // Add archived phases first (oldest milestones first) - const archived = getArchivedPhaseDirs(cwd); - for (const a of archived) { - allPhaseDirs.push({ name: a.name, fullPath: a.fullPath, milestone: a.milestone }); - } - - // Add current phases - if (fs.existsSync(phasesDir)) { - try { - const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name) - .sort(); - for (const dir of currentDirs) { - allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null }); - } - } catch {} - } - - if (allPhaseDirs.length === 0) { - digest.tech_stack = []; - output(digest, raw); - return; - } - - try { - for (const { name: dir, fullPath: dirPath } of allPhaseDirs) { - const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - - for (const summary of summaries) { - try { - const content = fs.readFileSync(path.join(dirPath, summary), 'utf-8'); - const fm = extractFrontmatter(content); - - const phaseNum = fm.phase || dir.split('-')[0]; - - if (!digest.phases[phaseNum]) { - digest.phases[phaseNum] = { - name: fm.name || dir.split('-').slice(1).join(' ') || 'Unknown', - provides: new Set(), - affects: new Set(), - patterns: new Set(), - }; - } - - // Merge provides - if (fm['dependency-graph'] && fm['dependency-graph'].provides) { - fm['dependency-graph'].provides.forEach(p => digest.phases[phaseNum].provides.add(p)); - } else if (fm.provides) { - fm.provides.forEach(p => digest.phases[phaseNum].provides.add(p)); - } - - // Merge affects - if (fm['dependency-graph'] && fm['dependency-graph'].affects) { - fm['dependency-graph'].affects.forEach(a => digest.phases[phaseNum].affects.add(a)); - } - - // Merge patterns - if (fm['patterns-established']) { - fm['patterns-established'].forEach(p => digest.phases[phaseNum].patterns.add(p)); - } - - // Merge decisions - if (fm['key-decisions']) { - fm['key-decisions'].forEach(d => { - digest.decisions.push({ phase: phaseNum, decision: d }); - }); - } - - // Merge tech stack - if (fm['tech-stack'] && fm['tech-stack'].added) { - fm['tech-stack'].added.forEach(t => digest.tech_stack.add(typeof t === 'string' ? t : t.name)); - } - - } catch (e) { - // Skip malformed summaries - } - } - } - - // Convert Sets to Arrays for JSON output - Object.keys(digest.phases).forEach(p => { - digest.phases[p].provides = [...digest.phases[p].provides]; - digest.phases[p].affects = [...digest.phases[p].affects]; - digest.phases[p].patterns = [...digest.phases[p].patterns]; - }); - digest.tech_stack = [...digest.tech_stack]; - - output(digest, raw); - } catch (e) { - error('Failed to generate history digest: ' + e.message); - } -} - -function cmdPhasesList(cwd, options, raw) { - const phasesDir = path.join(cwd, '.planning', 'phases'); - const { type, phase, includeArchived } = options; - - // If no phases directory, return empty - if (!fs.existsSync(phasesDir)) { - if (type) { - output({ files: [], count: 0 }, raw, ''); - } else { - output({ directories: [], count: 0 }, raw, ''); - } - return; - } - - try { - // Get all phase directories - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - let dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - - // Include archived phases if requested - if (includeArchived) { - const archived = getArchivedPhaseDirs(cwd); - for (const a of archived) { - dirs.push(`${a.name} [${a.milestone}]`); - } - } - - // Sort numerically (handles decimals: 01, 02, 02.1, 02.2, 03) - dirs.sort((a, b) => { - const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); - const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); - return aNum - bNum; - }); - - // If filtering by phase number - if (phase) { - const normalized = normalizePhaseName(phase); - const match = dirs.find(d => d.startsWith(normalized)); - if (!match) { - output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, ''); - return; - } - dirs = [match]; - } - - // If listing files of a specific type - if (type) { - const files = []; - for (const dir of dirs) { - const dirPath = path.join(phasesDir, dir); - const dirFiles = fs.readdirSync(dirPath); - - let filtered; - if (type === 'plans') { - filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - } else if (type === 'summaries') { - filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - } else { - filtered = dirFiles; - } - - files.push(...filtered.sort()); - } - - const result = { - files, - count: files.length, - phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)?-?/, '') : null, - }; - output(result, raw, files.join('\n')); - return; - } - - // Default: list directories - output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n')); - } catch (e) { - error('Failed to list phases: ' + e.message); - } -} - -function cmdRoadmapGetPhase(cwd, phaseNum, raw) { - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - - if (!fs.existsSync(roadmapPath)) { - output({ found: false, error: 'ROADMAP.md not found' }, raw, ''); - return; - } - - try { - const content = fs.readFileSync(roadmapPath, 'utf-8'); - - // Escape special regex chars in phase number, handle decimal - const escapedPhase = phaseNum.replace(/\./g, '\\.'); - - // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name - const phasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, - 'i' - ); - const headerMatch = content.match(phasePattern); - - if (!headerMatch) { - // Fallback: check if phase exists in summary list but missing detail section - const checklistPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, - 'i' - ); - const checklistMatch = content.match(checklistPattern); - - if (checklistMatch) { - // Phase exists in summary but missing detail section - malformed ROADMAP - output({ - found: false, - phase_number: phaseNum, - phase_name: checklistMatch[1].trim(), - error: 'malformed_roadmap', - message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.` - }, raw, ''); - return; - } - - output({ found: false, phase_number: phaseNum }, raw, ''); - return; - } - - const phaseName = headerMatch[1].trim(); - const headerIndex = headerMatch.index; - - // Find the end of this section (next ## or ### phase header, or end of file) - const restOfContent = content.slice(headerIndex); - const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); - const sectionEnd = nextHeaderMatch - ? headerIndex + nextHeaderMatch.index - : content.length; - - const section = content.slice(headerIndex, sectionEnd).trim(); - - // Extract goal if present - const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); - const goal = goalMatch ? goalMatch[1].trim() : null; - - // Extract success criteria as structured array - const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i); - const success_criteria = criteriaMatch - ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean) - : []; - - output( - { - found: true, - phase_number: phaseNum, - phase_name: phaseName, - goal, - success_criteria, - section, - }, - raw, - section - ); - } catch (e) { - error('Failed to read ROADMAP.md: ' + e.message); - } -} - -function cmdPhaseNextDecimal(cwd, basePhase, raw) { - const phasesDir = path.join(cwd, '.planning', 'phases'); - const normalized = normalizePhaseName(basePhase); - - // Check if phases directory exists - if (!fs.existsSync(phasesDir)) { - output( - { - found: false, - base_phase: normalized, - next: `${normalized}.1`, - existing: [], - }, - raw, - `${normalized}.1` - ); - return; - } - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - - // Check if base phase exists - const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized); - - // Find existing decimal phases for this base - const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`); - const existingDecimals = []; - - for (const dir of dirs) { - const match = dir.match(decimalPattern); - if (match) { - existingDecimals.push(`${normalized}.${match[1]}`); - } - } - - // Sort numerically - existingDecimals.sort((a, b) => { - const aNum = parseFloat(a); - const bNum = parseFloat(b); - return aNum - bNum; - }); - - // Calculate next decimal - let nextDecimal; - if (existingDecimals.length === 0) { - nextDecimal = `${normalized}.1`; - } else { - const lastDecimal = existingDecimals[existingDecimals.length - 1]; - const lastNum = parseInt(lastDecimal.split('.')[1], 10); - nextDecimal = `${normalized}.${lastNum + 1}`; - } - - output( - { - found: baseExists, - base_phase: normalized, - next: nextDecimal, - existing: existingDecimals, - }, - raw, - nextDecimal - ); - } catch (e) { - error('Failed to calculate next decimal phase: ' + e.message); - } -} - -function cmdStateLoad(cwd, raw) { - const config = loadConfig(cwd); - const planningDir = path.join(cwd, '.planning'); - - let stateRaw = ''; - try { - stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8'); - } catch {} - - const configExists = fs.existsSync(path.join(planningDir, 'config.json')); - const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md')); - const stateExists = stateRaw.length > 0; - - const result = { - config, - state_raw: stateRaw, - state_exists: stateExists, - roadmap_exists: roadmapExists, - config_exists: configExists, - }; - - // For --raw, output a condensed key=value format - if (raw) { - const c = config; - const lines = [ - `model_profile=${c.model_profile}`, - `commit_docs=${c.commit_docs}`, - `branching_strategy=${c.branching_strategy}`, - `phase_branch_template=${c.phase_branch_template}`, - `milestone_branch_template=${c.milestone_branch_template}`, - `parallelization=${c.parallelization}`, - `research=${c.research}`, - `plan_checker=${c.plan_checker}`, - `verifier=${c.verifier}`, - `config_exists=${configExists}`, - `roadmap_exists=${roadmapExists}`, - `state_exists=${stateExists}`, - ]; - process.stdout.write(lines.join('\n')); - process.exit(0); - } - - output(result); -} - -function cmdStateGet(cwd, section, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - try { - const content = fs.readFileSync(statePath, 'utf-8'); - - if (!section) { - output({ content }, raw, content); - return; - } - - // Try to find markdown section or field - const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - // Check for **field:** value - const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i'); - const fieldMatch = content.match(fieldPattern); - if (fieldMatch) { - output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim()); - return; - } - - // Check for ## Section - const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i'); - const sectionMatch = content.match(sectionPattern); - if (sectionMatch) { - output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim()); - return; - } - - output({ error: `Section or field "${section}" not found` }, raw, ''); - } catch { - error('STATE.md not found'); - } -} - -function cmdStatePatch(cwd, patches, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - try { - let content = fs.readFileSync(statePath, 'utf-8'); - const results = { updated: [], failed: [] }; - - for (const [field, value] of Object.entries(patches)) { - const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); - - if (pattern.test(content)) { - content = content.replace(pattern, `$1${value}`); - results.updated.push(field); - } else { - results.failed.push(field); - } - } - - if (results.updated.length > 0) { - fs.writeFileSync(statePath, content, 'utf-8'); - } - - output(results, raw, results.updated.length > 0 ? 'true' : 'false'); - } catch { - error('STATE.md not found'); - } -} - -function cmdStateUpdate(cwd, field, value) { - if (!field || value === undefined) { - error('field and value required for state update'); - } - - const statePath = path.join(cwd, '.planning', 'STATE.md'); - try { - let content = fs.readFileSync(statePath, 'utf-8'); - const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); - if (pattern.test(content)) { - content = content.replace(pattern, `$1${value}`); - fs.writeFileSync(statePath, content, 'utf-8'); - output({ updated: true }); - } else { - output({ updated: false, reason: `Field "${field}" not found in STATE.md` }); - } - } catch { - output({ updated: false, reason: 'STATE.md not found' }); - } -} - -// ─── State Progression Engine ──────────────────────────────────────────────── - -function stateExtractField(content, fieldName) { - const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); - const match = content.match(pattern); - return match ? match[1].trim() : null; -} - -function stateReplaceField(content, fieldName, newValue) { - const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'); - if (pattern.test(content)) { - return content.replace(pattern, `$1${newValue}`); - } - return null; -} - -function cmdStateAdvancePlan(cwd, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - - let content = fs.readFileSync(statePath, 'utf-8'); - const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10); - const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10); - const today = new Date().toISOString().split('T')[0]; - - if (isNaN(currentPlan) || isNaN(totalPlans)) { - output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw); - return; - } - - if (currentPlan >= totalPlans) { - content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content; - content = stateReplaceField(content, 'Last Activity', today) || content; - fs.writeFileSync(statePath, content, 'utf-8'); - output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false'); - } else { - const newPlan = currentPlan + 1; - content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content; - content = stateReplaceField(content, 'Status', 'Ready to execute') || content; - content = stateReplaceField(content, 'Last Activity', today) || content; - fs.writeFileSync(statePath, content, 'utf-8'); - output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true'); - } -} - -function cmdStateRecordMetric(cwd, options, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - - let content = fs.readFileSync(statePath, 'utf-8'); - const { phase, plan, duration, tasks, files } = options; - - if (!phase || !plan || !duration) { - output({ error: 'phase, plan, and duration required' }, raw); - return; - } - - // Find Performance Metrics section and its table - const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; - const metricsMatch = content.match(metricsPattern); - - if (metricsMatch) { - const tableHeader = metricsMatch[1]; - let tableBody = metricsMatch[2].trimEnd(); - const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`; - - if (tableBody.trim() === '' || tableBody.includes('None yet')) { - tableBody = newRow; - } else { - tableBody = tableBody + '\n' + newRow; - } - - content = content.replace(metricsPattern, `${tableHeader}${tableBody}\n`); - fs.writeFileSync(statePath, content, 'utf-8'); - output({ recorded: true, phase, plan, duration }, raw, 'true'); - } else { - output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false'); - } -} - -function cmdStateUpdateProgress(cwd, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - - let content = fs.readFileSync(statePath, 'utf-8'); - - // Count summaries across all phases - const phasesDir = path.join(cwd, '.planning', 'phases'); - let totalPlans = 0; - let totalSummaries = 0; - - if (fs.existsSync(phasesDir)) { - const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) - .filter(e => e.isDirectory()).map(e => e.name); - for (const dir of phaseDirs) { - const files = fs.readdirSync(path.join(phasesDir, dir)); - totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length; - totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length; - } - } - - const percent = totalPlans > 0 ? Math.round(totalSummaries / totalPlans * 100) : 0; - const barWidth = 10; - const filled = Math.round(percent / 100 * barWidth); - const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); - const progressStr = `[${bar}] ${percent}%`; - - const progressPattern = /(\*\*Progress:\*\*\s*).*/i; - if (progressPattern.test(content)) { - content = content.replace(progressPattern, `$1${progressStr}`); - fs.writeFileSync(statePath, content, 'utf-8'); - output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr); - } else { - output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false'); - } -} - -function cmdStateAddDecision(cwd, options, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - - const { phase, summary, rationale } = options; - if (!summary) { output({ error: 'summary required' }, raw); return; } - - let content = fs.readFileSync(statePath, 'utf-8'); - const entry = `- [Phase ${phase || '?'}]: ${summary}${rationale ? ` — ${rationale}` : ''}`; - - // Find Decisions section (various heading patterns) - const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const match = content.match(sectionPattern); - - if (match) { - let sectionBody = match[2]; - // Remove placeholders - sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); - sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; - content = content.replace(sectionPattern, `${match[1]}${sectionBody}`); - fs.writeFileSync(statePath, content, 'utf-8'); - output({ added: true, decision: entry }, raw, 'true'); - } else { - output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false'); - } -} - -function cmdStateAddBlocker(cwd, text, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - if (!text) { output({ error: 'text required' }, raw); return; } - - let content = fs.readFileSync(statePath, 'utf-8'); - const entry = `- ${text}`; - - const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const match = content.match(sectionPattern); - - if (match) { - let sectionBody = match[2]; - sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); - sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; - content = content.replace(sectionPattern, `${match[1]}${sectionBody}`); - fs.writeFileSync(statePath, content, 'utf-8'); - output({ added: true, blocker: text }, raw, 'true'); - } else { - output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); - } -} - -function cmdStateResolveBlocker(cwd, text, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - if (!text) { output({ error: 'text required' }, raw); return; } - - let content = fs.readFileSync(statePath, 'utf-8'); - - const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const match = content.match(sectionPattern); - - if (match) { - const sectionBody = match[2]; - const lines = sectionBody.split('\n'); - const filtered = lines.filter(line => { - if (!line.startsWith('- ')) return true; - return !line.toLowerCase().includes(text.toLowerCase()); - }); - - let newBody = filtered.join('\n'); - // If section is now empty, add placeholder - if (!newBody.trim() || !newBody.includes('- ')) { - newBody = 'None\n'; - } - - content = content.replace(sectionPattern, `${match[1]}${newBody}`); - fs.writeFileSync(statePath, content, 'utf-8'); - output({ resolved: true, blocker: text }, raw, 'true'); - } else { - output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); - } -} - -function cmdStateRecordSession(cwd, options, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - - let content = fs.readFileSync(statePath, 'utf-8'); - const now = new Date().toISOString(); - const updated = []; - - // Update Last session / Last Date - let result = stateReplaceField(content, 'Last session', now); - if (result) { content = result; updated.push('Last session'); } - result = stateReplaceField(content, 'Last Date', now); - if (result) { content = result; updated.push('Last Date'); } - - // Update Stopped at - if (options.stopped_at) { - result = stateReplaceField(content, 'Stopped At', options.stopped_at); - if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at); - if (result) { content = result; updated.push('Stopped At'); } - } - - // Update Resume file - const resumeFile = options.resume_file || 'None'; - result = stateReplaceField(content, 'Resume File', resumeFile); - if (!result) result = stateReplaceField(content, 'Resume file', resumeFile); - if (result) { content = result; updated.push('Resume File'); } - - if (updated.length > 0) { - fs.writeFileSync(statePath, content, 'utf-8'); - output({ recorded: true, updated }, raw, 'true'); - } else { - output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false'); - } -} - -function cmdResolveModel(cwd, agentType, raw) { - if (!agentType) { - error('agent-type required'); - } - - const config = loadConfig(cwd); - const profile = config.model_profile || 'balanced'; - - const agentModels = MODEL_PROFILES[agentType]; - if (!agentModels) { - const result = { model: 'sonnet', profile, unknown_agent: true }; - output(result, raw, 'sonnet'); - return; - } - - const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet'; - const model = resolved === 'opus' ? 'inherit' : resolved; - const result = { model, profile }; - output(result, raw, model); -} - -function cmdFindPhase(cwd, phase, raw) { - if (!phase) { - error('phase identifier required'); - } - - const phasesDir = path.join(cwd, '.planning', 'phases'); - const normalized = normalizePhaseName(phase); - - const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] }; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - - const match = dirs.find(d => d.startsWith(normalized)); - if (!match) { - output(notFound, raw, ''); - return; - } - - const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/); - const phaseNumber = dirMatch ? dirMatch[1] : normalized; - const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; - - const phaseDir = path.join(phasesDir, match); - const phaseFiles = fs.readdirSync(phaseDir); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); - - const result = { - found: true, - directory: path.join('.planning', 'phases', match), - phase_number: phaseNumber, - phase_name: phaseName, - plans, - summaries, - }; - - output(result, raw, result.directory); - } catch { - output(notFound, raw, ''); - } -} - -function cmdCommit(cwd, message, files, raw, amend) { - if (!message && !amend) { - error('commit message required'); - } - - const config = loadConfig(cwd); - - // Check commit_docs config - if (!config.commit_docs) { - const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' }; - output(result, raw, 'skipped'); - return; - } - - // Check if .planning is gitignored - if (isGitIgnored(cwd, '.planning')) { - const result = { committed: false, hash: null, reason: 'skipped_gitignored' }; - output(result, raw, 'skipped'); - return; - } - - // Stage files - const filesToStage = files && files.length > 0 ? files : ['.planning/']; - for (const file of filesToStage) { - execGit(cwd, ['add', file]); - } - - // Commit - const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message]; - const commitResult = execGit(cwd, commitArgs); - if (commitResult.exitCode !== 0) { - if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) { - const result = { committed: false, hash: null, reason: 'nothing_to_commit' }; - output(result, raw, 'nothing'); - return; - } - const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr }; - output(result, raw, 'nothing'); - return; - } - - // Get short hash - const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']); - const hash = hashResult.exitCode === 0 ? hashResult.stdout : null; - const result = { committed: true, hash, reason: 'committed' }; - output(result, raw, hash || 'committed'); -} - -function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) { - if (!summaryPath) { - error('summary-path required'); - } - - const fullPath = path.join(cwd, summaryPath); - const checkCount = checkFileCount || 2; - - // Check 1: Summary exists - if (!fs.existsSync(fullPath)) { - const result = { - passed: false, - checks: { - summary_exists: false, - files_created: { checked: 0, found: 0, missing: [] }, - commits_exist: false, - self_check: 'not_found', - }, - errors: ['SUMMARY.md not found'], - }; - output(result, raw, 'failed'); - return; - } - - const content = fs.readFileSync(fullPath, 'utf-8'); - const errors = []; - - // Check 2: Spot-check files mentioned in summary - const mentionedFiles = new Set(); - const patterns = [ - /`([^`]+\.[a-zA-Z]+)`/g, - /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi, - ]; - - for (const pattern of patterns) { - let m; - while ((m = pattern.exec(content)) !== null) { - const filePath = m[1]; - if (filePath && !filePath.startsWith('http') && filePath.includes('/')) { - mentionedFiles.add(filePath); - } - } - } - - const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount); - const missing = []; - for (const file of filesToCheck) { - if (!fs.existsSync(path.join(cwd, file))) { - missing.push(file); - } - } - - // Check 3: Commits exist - const commitHashPattern = /\b[0-9a-f]{7,40}\b/g; - const hashes = content.match(commitHashPattern) || []; - let commitsExist = false; - if (hashes.length > 0) { - for (const hash of hashes.slice(0, 3)) { - const result = execGit(cwd, ['cat-file', '-t', hash]); - if (result.exitCode === 0 && result.stdout === 'commit') { - commitsExist = true; - break; - } - } - } - - // Check 4: Self-check section - let selfCheck = 'not_found'; - const selfCheckPattern = /##\s*(?:Self[- ]?Check|Verification|Quality Check)/i; - if (selfCheckPattern.test(content)) { - const passPattern = /(?:all\s+)?(?:pass|✓|✅|complete|succeeded)/i; - const failPattern = /(?:fail|✗|❌|incomplete|blocked)/i; - const checkSection = content.slice(content.search(selfCheckPattern)); - if (failPattern.test(checkSection)) { - selfCheck = 'failed'; - } else if (passPattern.test(checkSection)) { - selfCheck = 'passed'; - } - } - - if (missing.length > 0) errors.push('Missing files: ' + missing.join(', ')); - if (!commitsExist && hashes.length > 0) errors.push('Referenced commit hashes not found in git history'); - if (selfCheck === 'failed') errors.push('Self-check section indicates failure'); - - const checks = { - summary_exists: true, - files_created: { checked: filesToCheck.length, found: filesToCheck.length - missing.length, missing }, - commits_exist: commitsExist, - self_check: selfCheck, - }; - - const passed = missing.length === 0 && selfCheck !== 'failed'; - const result = { passed, checks, errors }; - output(result, raw, passed ? 'passed' : 'failed'); -} - -function cmdTemplateSelect(cwd, planPath, raw) { - if (!planPath) { - error('plan-path required'); - } - - try { - const fullPath = path.join(cwd, planPath); - const content = fs.readFileSync(fullPath, 'utf-8'); - - // Simple heuristics - const taskMatch = content.match(/###\s*Task\s*\d+/g) || []; - const taskCount = taskMatch.length; - - const decisionMatch = content.match(/decision/gi) || []; - const hasDecisions = decisionMatch.length > 0; - - // Count file mentions - const fileMentions = new Set(); - const filePattern = /`([^`]+\.[a-zA-Z]+)`/g; - let m; - while ((m = filePattern.exec(content)) !== null) { - if (m[1].includes('/') && !m[1].startsWith('http')) { - fileMentions.add(m[1]); - } - } - const fileCount = fileMentions.size; - - let template = 'templates/summary-standard.md'; - let type = 'standard'; - - if (taskCount <= 2 && fileCount <= 3 && !hasDecisions) { - template = 'templates/summary-minimal.md'; - type = 'minimal'; - } else if (hasDecisions || fileCount > 6 || taskCount > 5) { - template = 'templates/summary-complex.md'; - type = 'complex'; - } - - const result = { template, type, taskCount, fileCount, hasDecisions }; - output(result, raw, template); - } catch (e) { - // Fallback to standard - output({ template: 'templates/summary-standard.md', type: 'standard', error: e.message }, raw, 'templates/summary-standard.md'); - } -} - -function cmdTemplateFill(cwd, templateType, options, raw) { - if (!templateType) { error('template type required: summary, plan, or verification'); } - if (!options.phase) { error('--phase required'); } - - const phaseInfo = findPhaseInternal(cwd, options.phase); - if (!phaseInfo || !phaseInfo.found) { output({ error: 'Phase not found', phase: options.phase }, raw); return; } - - const padded = normalizePhaseName(options.phase); - const today = new Date().toISOString().split('T')[0]; - const phaseName = options.name || phaseInfo.phase_name || 'Unnamed'; - const phaseSlug = phaseInfo.phase_slug || generateSlugInternal(phaseName); - const phaseId = `${padded}-${phaseSlug}`; - const planNum = (options.plan || '01').padStart(2, '0'); - const fields = options.fields || {}; - - let frontmatter, body, fileName; - - switch (templateType) { - case 'summary': { - frontmatter = { - phase: phaseId, - plan: planNum, - subsystem: '[primary category]', - tags: [], - provides: [], - affects: [], - 'tech-stack': { added: [], patterns: [] }, - 'key-files': { created: [], modified: [] }, - 'key-decisions': [], - 'patterns-established': [], - duration: '[X]min', - completed: today, - ...fields, - }; - body = [ - `# Phase ${options.phase}: ${phaseName} Summary`, - '', - '**[Substantive one-liner describing outcome]**', - '', - '## Performance', - '- **Duration:** [time]', - '- **Tasks:** [count completed]', - '- **Files modified:** [count]', - '', - '## Accomplishments', - '- [Key outcome 1]', - '- [Key outcome 2]', - '', - '## Task Commits', - '1. **Task 1: [task name]** - `hash`', - '', - '## Files Created/Modified', - '- `path/to/file.ts` - What it does', - '', - '## Decisions & Deviations', - '[Key decisions or "None - followed plan as specified"]', - '', - '## Next Phase Readiness', - '[What\'s ready for next phase]', - ].join('\n'); - fileName = `${padded}-${planNum}-SUMMARY.md`; - break; - } - case 'plan': { - const planType = options.type || 'execute'; - const wave = parseInt(options.wave) || 1; - frontmatter = { - phase: phaseId, - plan: planNum, - type: planType, - wave, - depends_on: [], - files_modified: [], - autonomous: true, - user_setup: [], - must_haves: { truths: [], artifacts: [], key_links: [] }, - ...fields, - }; - body = [ - `# Phase ${options.phase} Plan ${planNum}: [Title]`, - '', - '## Objective', - '- **What:** [What this plan builds]', - '- **Why:** [Why it matters for the phase goal]', - '- **Output:** [Concrete deliverable]', - '', - '## Context', - '@.planning/PROJECT.md', - '@.planning/ROADMAP.md', - '@.planning/STATE.md', - '', - '## Tasks', - '', - '', - ' [Task name]', - ' [file paths]', - ' [What to do]', - ' [How to verify]', - ' [Definition of done]', - '', - '', - '## Verification', - '[How to verify this plan achieved its objective]', - '', - '## Success Criteria', - '- [ ] [Criterion 1]', - '- [ ] [Criterion 2]', - ].join('\n'); - fileName = `${padded}-${planNum}-PLAN.md`; - break; - } - case 'verification': { - frontmatter = { - phase: phaseId, - verified: new Date().toISOString(), - status: 'pending', - score: '0/0 must-haves verified', - ...fields, - }; - body = [ - `# Phase ${options.phase}: ${phaseName} — Verification`, - '', - '## Observable Truths', - '| # | Truth | Status | Evidence |', - '|---|-------|--------|----------|', - '| 1 | [Truth] | pending | |', - '', - '## Required Artifacts', - '| Artifact | Expected | Status | Details |', - '|----------|----------|--------|---------|', - '| [path] | [what] | pending | |', - '', - '## Key Link Verification', - '| From | To | Via | Status | Details |', - '|------|----|----|--------|---------|', - '| [source] | [target] | [connection] | pending | |', - '', - '## Requirements Coverage', - '| Requirement | Status | Blocking Issue |', - '|-------------|--------|----------------|', - '| [req] | pending | |', - '', - '## Result', - '[Pending verification]', - ].join('\n'); - fileName = `${padded}-VERIFICATION.md`; - break; - } - default: - error(`Unknown template type: ${templateType}. Available: summary, plan, verification`); - return; - } - - const fullContent = `---\n${reconstructFrontmatter(frontmatter)}\n---\n\n${body}\n`; - const outPath = path.join(cwd, phaseInfo.directory, fileName); - - if (fs.existsSync(outPath)) { - output({ error: 'File already exists', path: path.relative(cwd, outPath) }, raw); - return; - } - - fs.writeFileSync(outPath, fullContent, 'utf-8'); - const relPath = path.relative(cwd, outPath); - output({ created: true, path: relPath, template: templateType }, raw, relPath); -} - -function cmdPhasePlanIndex(cwd, phase, raw) { - if (!phase) { - error('phase required for phase-plan-index'); - } - - const phasesDir = path.join(cwd, '.planning', 'phases'); - const normalized = normalizePhaseName(phase); - - // Find phase directory - let phaseDir = null; - let phaseDirName = null; - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - const match = dirs.find(d => d.startsWith(normalized)); - if (match) { - phaseDir = path.join(phasesDir, match); - phaseDirName = match; - } - } catch { - // phases dir doesn't exist - } - - if (!phaseDir) { - output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw); - return; - } - - // Get all files in phase directory - const phaseFiles = fs.readdirSync(phaseDir); - const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); - const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - - // Build set of plan IDs with summaries - const completedPlanIds = new Set( - summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) - ); - - const plans = []; - const waves = {}; - const incomplete = []; - let hasCheckpoints = false; - - for (const planFile of planFiles) { - const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', ''); - const planPath = path.join(phaseDir, planFile); - const content = fs.readFileSync(planPath, 'utf-8'); - const fm = extractFrontmatter(content); - - // Count tasks (## Task N patterns) - const taskMatches = content.match(/##\s*Task\s*\d+/gi) || []; - const taskCount = taskMatches.length; - - // Parse wave as integer - const wave = parseInt(fm.wave, 10) || 1; - - // Parse autonomous (default true if not specified) - let autonomous = true; - if (fm.autonomous !== undefined) { - autonomous = fm.autonomous === 'true' || fm.autonomous === true; - } - - if (!autonomous) { - hasCheckpoints = true; - } - - // Parse files-modified - let filesModified = []; - if (fm['files-modified']) { - filesModified = Array.isArray(fm['files-modified']) ? fm['files-modified'] : [fm['files-modified']]; - } - - const hasSummary = completedPlanIds.has(planId); - if (!hasSummary) { - incomplete.push(planId); - } - - const plan = { - id: planId, - wave, - autonomous, - objective: fm.objective || null, - files_modified: filesModified, - task_count: taskCount, - has_summary: hasSummary, - }; - - plans.push(plan); - - // Group by wave - const waveKey = String(wave); - if (!waves[waveKey]) { - waves[waveKey] = []; - } - waves[waveKey].push(planId); - } - - const result = { - phase: normalized, - plans, - waves, - incomplete, - has_checkpoints: hasCheckpoints, - }; - - output(result, raw); -} - -function cmdStateSnapshot(cwd, raw) { - const statePath = path.join(cwd, '.planning', 'STATE.md'); - - if (!fs.existsSync(statePath)) { - output({ error: 'STATE.md not found' }, raw); - return; - } - - const content = fs.readFileSync(statePath, 'utf-8'); - - // Helper to extract **Field:** value patterns - const extractField = (fieldName) => { - const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); - const match = content.match(pattern); - return match ? match[1].trim() : null; - }; - - // Extract basic fields - const currentPhase = extractField('Current Phase'); - const currentPhaseName = extractField('Current Phase Name'); - const totalPhasesRaw = extractField('Total Phases'); - const currentPlan = extractField('Current Plan'); - const totalPlansRaw = extractField('Total Plans in Phase'); - const status = extractField('Status'); - const progressRaw = extractField('Progress'); - const lastActivity = extractField('Last Activity'); - const lastActivityDesc = extractField('Last Activity Description'); - const pausedAt = extractField('Paused At'); - - // Parse numeric fields - const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; - const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; - const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null; - - // Extract decisions table - const decisions = []; - const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); - if (decisionsMatch) { - const tableBody = decisionsMatch[1]; - const rows = tableBody.trim().split('\n').filter(r => r.includes('|')); - for (const row of rows) { - const cells = row.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 3) { - decisions.push({ - phase: cells[0], - summary: cells[1], - rationale: cells[2], - }); - } - } - } - - // Extract blockers list - const blockers = []; - const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); - if (blockersMatch) { - const blockersSection = blockersMatch[1]; - const items = blockersSection.match(/^-\s+(.+)$/gm) || []; - for (const item of items) { - blockers.push(item.replace(/^-\s+/, '').trim()); - } - } - - // Extract session info - const session = { - last_date: null, - stopped_at: null, - resume_file: null, - }; - - const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); - if (sessionMatch) { - const sessionSection = sessionMatch[1]; - const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i); - const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i); - const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i); - - if (lastDateMatch) session.last_date = lastDateMatch[1].trim(); - if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim(); - if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim(); - } - - const result = { - current_phase: currentPhase, - current_phase_name: currentPhaseName, - total_phases: totalPhases, - current_plan: currentPlan, - total_plans_in_phase: totalPlansInPhase, - status, - progress_percent: progressPercent, - last_activity: lastActivity, - last_activity_desc: lastActivityDesc, - decisions, - blockers, - paused_at: pausedAt, - session, - }; - - output(result, raw); -} - -function cmdSummaryExtract(cwd, summaryPath, fields, raw) { - if (!summaryPath) { - error('summary-path required for summary-extract'); - } - - const fullPath = path.join(cwd, summaryPath); - - if (!fs.existsSync(fullPath)) { - output({ error: 'File not found', path: summaryPath }, raw); - return; - } - - const content = fs.readFileSync(fullPath, 'utf-8'); - const fm = extractFrontmatter(content); - - // Parse key-decisions into structured format - const parseDecisions = (decisionsList) => { - if (!decisionsList || !Array.isArray(decisionsList)) return []; - return decisionsList.map(d => { - const colonIdx = d.indexOf(':'); - if (colonIdx > 0) { - return { - summary: d.substring(0, colonIdx).trim(), - rationale: d.substring(colonIdx + 1).trim(), - }; - } - return { summary: d, rationale: null }; - }); - }; - - // Build full result - const fullResult = { - path: summaryPath, - one_liner: fm['one-liner'] || null, - key_files: fm['key-files'] || [], - tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [], - patterns: fm['patterns-established'] || [], - decisions: parseDecisions(fm['key-decisions']), - }; - - // If fields specified, filter to only those fields - if (fields && fields.length > 0) { - const filtered = { path: summaryPath }; - for (const field of fields) { - if (fullResult[field] !== undefined) { - filtered[field] = fullResult[field]; - } - } - output(filtered, raw); - return; - } - - output(fullResult, raw); -} - -// ─── Web Search (Brave API) ────────────────────────────────────────────────── - -async function cmdWebsearch(query, options, raw) { - const apiKey = process.env.BRAVE_API_KEY; - - if (!apiKey) { - // No key = silent skip, agent falls back to built-in WebSearch - output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, ''); - return; - } - - if (!query) { - output({ available: false, error: 'Query required' }, raw, ''); - return; - } - - const params = new URLSearchParams({ - q: query, - count: String(options.limit || 10), - country: 'us', - search_lang: 'en', - text_decorations: 'false' - }); - - if (options.freshness) { - params.set('freshness', options.freshness); - } - - try { - const response = await fetch( - `https://api.search.brave.com/res/v1/web/search?${params}`, - { - headers: { - 'Accept': 'application/json', - 'X-Subscription-Token': apiKey - } - } - ); - - if (!response.ok) { - output({ available: false, error: `API error: ${response.status}` }, raw, ''); - return; - } - - const data = await response.json(); - - const results = (data.web?.results || []).map(r => ({ - title: r.title, - url: r.url, - description: r.description, - age: r.age || null - })); - - output({ - available: true, - query, - count: results.length, - results - }, raw, results.map(r => `${r.title}\n${r.url}\n${r.description}`).join('\n\n')); - } catch (err) { - output({ available: false, error: err.message }, raw, ''); - } -} - -// ─── Frontmatter CRUD ──────────────────────────────────────────────────────── - -function cmdFrontmatterGet(cwd, filePath, field, raw) { - if (!filePath) { error('file path required'); } - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); - const content = safeReadFile(fullPath); - if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } - const fm = extractFrontmatter(content); - if (field) { - const value = fm[field]; - if (value === undefined) { output({ error: 'Field not found', field }, raw); return; } - output({ [field]: value }, raw, JSON.stringify(value)); - } else { - output(fm, raw); - } -} - -function cmdFrontmatterSet(cwd, filePath, field, value, raw) { - if (!filePath || !field || value === undefined) { error('file, field, and value required'); } - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); - if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; } - const content = fs.readFileSync(fullPath, 'utf-8'); - const fm = extractFrontmatter(content); - let parsedValue; - try { parsedValue = JSON.parse(value); } catch { parsedValue = value; } - fm[field] = parsedValue; - const newContent = spliceFrontmatter(content, fm); - fs.writeFileSync(fullPath, newContent, 'utf-8'); - output({ updated: true, field, value: parsedValue }, raw, 'true'); -} - -function cmdFrontmatterMerge(cwd, filePath, data, raw) { - if (!filePath || !data) { error('file and data required'); } - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); - if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; } - const content = fs.readFileSync(fullPath, 'utf-8'); - const fm = extractFrontmatter(content); - let mergeData; - try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; } - Object.assign(fm, mergeData); - const newContent = spliceFrontmatter(content, fm); - fs.writeFileSync(fullPath, newContent, 'utf-8'); - output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true'); -} - -const FRONTMATTER_SCHEMAS = { - plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] }, - summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] }, - verification: { required: ['phase', 'verified', 'status', 'score'] }, -}; - -function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) { - if (!filePath || !schemaName) { error('file and schema required'); } - const schema = FRONTMATTER_SCHEMAS[schemaName]; - if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); } - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); - const content = safeReadFile(fullPath); - if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } - const fm = extractFrontmatter(content); - const missing = schema.required.filter(f => fm[f] === undefined); - const present = schema.required.filter(f => fm[f] !== undefined); - output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid'); -} - -// ─── Verification Suite ────────────────────────────────────────────────────── - -function cmdVerifyPlanStructure(cwd, filePath, raw) { - if (!filePath) { error('file path required'); } - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); - const content = safeReadFile(fullPath); - if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } - - const fm = extractFrontmatter(content); - const errors = []; - const warnings = []; - - // Check required frontmatter fields - const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves']; - for (const field of required) { - if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`); - } - - // Parse and check task elements - const taskPattern = /]*>([\s\S]*?)<\/task>/g; - const tasks = []; - let taskMatch; - while ((taskMatch = taskPattern.exec(content)) !== null) { - const taskContent = taskMatch[1]; - const nameMatch = taskContent.match(/([\s\S]*?)<\/name>/); - const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed'; - const hasFiles = //.test(taskContent); - const hasAction = //.test(taskContent); - const hasVerify = //.test(taskContent); - const hasDone = //.test(taskContent); - - if (!nameMatch) errors.push('Task missing element'); - if (!hasAction) errors.push(`Task '${taskName}' missing `); - if (!hasVerify) warnings.push(`Task '${taskName}' missing `); - if (!hasDone) warnings.push(`Task '${taskName}' missing `); - if (!hasFiles) warnings.push(`Task '${taskName}' missing `); - - tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone }); - } - - if (tasks.length === 0) warnings.push('No elements found'); - - // Wave/depends_on consistency - if (fm.wave && parseInt(fm.wave) > 1 && (!fm.depends_on || (Array.isArray(fm.depends_on) && fm.depends_on.length === 0))) { - warnings.push('Wave > 1 but depends_on is empty'); - } - - // Autonomous/checkpoint consistency - const hasCheckpoints = / f.match(/-PLAN\.md$/i)); - const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)); - - // Extract plan IDs (everything before -PLAN.md) - const planIds = new Set(plans.map(p => p.replace(/-PLAN\.md$/i, ''))); - const summaryIds = new Set(summaries.map(s => s.replace(/-SUMMARY\.md$/i, ''))); - - // Plans without summaries - const incompletePlans = [...planIds].filter(id => !summaryIds.has(id)); - if (incompletePlans.length > 0) { - errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`); - } - - // Summaries without plans (orphans) - const orphanSummaries = [...summaryIds].filter(id => !planIds.has(id)); - if (orphanSummaries.length > 0) { - warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`); - } - - output({ - complete: errors.length === 0, - phase: phaseInfo.phase_number, - plan_count: plans.length, - summary_count: summaries.length, - incomplete_plans: incompletePlans, - orphan_summaries: orphanSummaries, - errors, - warnings, - }, raw, errors.length === 0 ? 'complete' : 'incomplete'); -} - -function cmdVerifyReferences(cwd, filePath, raw) { - if (!filePath) { error('file path required'); } - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); - const content = safeReadFile(fullPath); - if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } - - const found = []; - const missing = []; - - // Find @-references: @path/to/file (must contain / to be a file path) - const atRefs = content.match(/@([^\s\n,)]+\/[^\s\n,)]+)/g) || []; - for (const ref of atRefs) { - const cleanRef = ref.slice(1); // remove @ - const resolved = cleanRef.startsWith('~/') - ? path.join(process.env.HOME || '', cleanRef.slice(2)) - : path.join(cwd, cleanRef); - if (fs.existsSync(resolved)) { - found.push(cleanRef); - } else { - missing.push(cleanRef); - } - } - - // Find backtick file paths that look like real paths (contain / and have extension) - const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10})`/g) || []; - for (const ref of backtickRefs) { - const cleanRef = ref.slice(1, -1); // remove backticks - if (cleanRef.startsWith('http') || cleanRef.includes('${') || cleanRef.includes('{{')) continue; - if (found.includes(cleanRef) || missing.includes(cleanRef)) continue; // dedup - const resolved = path.join(cwd, cleanRef); - if (fs.existsSync(resolved)) { - found.push(cleanRef); - } else { - missing.push(cleanRef); - } - } - - output({ - valid: missing.length === 0, - found: found.length, - missing, - total: found.length + missing.length, - }, raw, missing.length === 0 ? 'valid' : 'invalid'); -} - -function cmdVerifyCommits(cwd, hashes, raw) { - if (!hashes || hashes.length === 0) { error('At least one commit hash required'); } - - const valid = []; - const invalid = []; - for (const hash of hashes) { - const result = execGit(cwd, ['cat-file', '-t', hash]); - if (result.exitCode === 0 && result.stdout.trim() === 'commit') { - valid.push(hash); - } else { - invalid.push(hash); - } - } - - output({ - all_valid: invalid.length === 0, - valid, - invalid, - total: hashes.length, - }, raw, invalid.length === 0 ? 'valid' : 'invalid'); -} - -function cmdVerifyArtifacts(cwd, planFilePath, raw) { - if (!planFilePath) { error('plan file path required'); } - const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath); - const content = safeReadFile(fullPath); - if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; } - - const artifacts = parseMustHavesBlock(content, 'artifacts'); - if (artifacts.length === 0) { - output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw); - return; - } - - const results = []; - for (const artifact of artifacts) { - if (typeof artifact === 'string') continue; // skip simple string items - const artPath = artifact.path; - if (!artPath) continue; - - const artFullPath = path.join(cwd, artPath); - const exists = fs.existsSync(artFullPath); - const check = { path: artPath, exists, issues: [], passed: false }; - - if (exists) { - const fileContent = safeReadFile(artFullPath) || ''; - const lineCount = fileContent.split('\n').length; - - if (artifact.min_lines && lineCount < artifact.min_lines) { - check.issues.push(`Only ${lineCount} lines, need ${artifact.min_lines}`); - } - if (artifact.contains && !fileContent.includes(artifact.contains)) { - check.issues.push(`Missing pattern: ${artifact.contains}`); - } - if (artifact.exports) { - const exports = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports]; - for (const exp of exports) { - if (!fileContent.includes(exp)) check.issues.push(`Missing export: ${exp}`); - } - } - check.passed = check.issues.length === 0; - } else { - check.issues.push('File not found'); - } - - results.push(check); - } - - const passed = results.filter(r => r.passed).length; - output({ - all_passed: passed === results.length, - passed, - total: results.length, - artifacts: results, - }, raw, passed === results.length ? 'valid' : 'invalid'); -} - -function cmdVerifyKeyLinks(cwd, planFilePath, raw) { - if (!planFilePath) { error('plan file path required'); } - const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath); - const content = safeReadFile(fullPath); - if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; } - - const keyLinks = parseMustHavesBlock(content, 'key_links'); - if (keyLinks.length === 0) { - output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw); - return; - } - - const results = []; - for (const link of keyLinks) { - if (typeof link === 'string') continue; - const check = { from: link.from, to: link.to, via: link.via || '', verified: false, detail: '' }; - - const sourceContent = safeReadFile(path.join(cwd, link.from || '')); - if (!sourceContent) { - check.detail = 'Source file not found'; - } else if (link.pattern) { - try { - const regex = new RegExp(link.pattern); - if (regex.test(sourceContent)) { - check.verified = true; - check.detail = 'Pattern found in source'; - } else { - const targetContent = safeReadFile(path.join(cwd, link.to || '')); - if (targetContent && regex.test(targetContent)) { - check.verified = true; - check.detail = 'Pattern found in target'; - } else { - check.detail = `Pattern "${link.pattern}" not found in source or target`; - } - } - } catch { - check.detail = `Invalid regex pattern: ${link.pattern}`; - } - } else { - // No pattern: just check source references target - if (sourceContent.includes(link.to || '')) { - check.verified = true; - check.detail = 'Target referenced in source'; - } else { - check.detail = 'Target not referenced in source'; - } - } - - results.push(check); - } - - const verified = results.filter(r => r.verified).length; - output({ - all_verified: verified === results.length, - verified, - total: results.length, - links: results, - }, raw, verified === results.length ? 'valid' : 'invalid'); -} - -// ─── Roadmap Analysis ───────────────────────────────────────────────────────── - -function cmdRoadmapAnalyze(cwd, raw) { - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - - if (!fs.existsSync(roadmapPath)) { - output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw); - return; - } - - const content = fs.readFileSync(roadmapPath, 'utf-8'); - const phasesDir = path.join(cwd, '.planning', 'phases'); - - // Extract all phase headings: ## Phase N: Name or ### Phase N: Name - const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:\s*([^\n]+)/gi; - const phases = []; - let match; - - while ((match = phasePattern.exec(content)) !== null) { - const phaseNum = match[1]; - const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim(); - - // Extract goal from the section - const sectionStart = match.index; - const restOfContent = content.slice(sectionStart); - const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); - const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length; - const section = content.slice(sectionStart, sectionEnd); - - const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); - const goal = goalMatch ? goalMatch[1].trim() : null; - - const dependsMatch = section.match(/\*\*Depends on:\*\*\s*([^\n]+)/i); - const depends_on = dependsMatch ? dependsMatch[1].trim() : null; - - // Check completion on disk - const normalized = normalizePhaseName(phaseNum); - let diskStatus = 'no_directory'; - let planCount = 0; - let summaryCount = 0; - let hasContext = false; - let hasResearch = false; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized); - - if (dirMatch) { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch)); - planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; - summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; - hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - - if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; - else if (summaryCount > 0) diskStatus = 'partial'; - else if (planCount > 0) diskStatus = 'planned'; - else if (hasResearch) diskStatus = 'researched'; - else if (hasContext) diskStatus = 'discussed'; - else diskStatus = 'empty'; - } - } catch {} - - // Check ROADMAP checkbox status - const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}`, 'i'); - const checkboxMatch = content.match(checkboxPattern); - const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false; - - phases.push({ - number: phaseNum, - name: phaseName, - goal, - depends_on, - plan_count: planCount, - summary_count: summaryCount, - has_context: hasContext, - has_research: hasResearch, - disk_status: diskStatus, - roadmap_complete: roadmapComplete, - }); - } - - // Extract milestone info - const milestones = []; - const milestonePattern = /##\s*(.*v(\d+\.\d+)[^(\n]*)/gi; - let mMatch; - while ((mMatch = milestonePattern.exec(content)) !== null) { - milestones.push({ - heading: mMatch[1].trim(), - version: 'v' + mMatch[2], - }); - } - - // Find current and next phase - const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null; - const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null; - - // Aggregated stats - const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0); - const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0); - const completedPhases = phases.filter(p => p.disk_status === 'complete').length; - - // Detect phases in summary list without detail sections (malformed ROADMAP) - const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+(?:\.\d+)?)/gi; - const checklistPhases = new Set(); - let checklistMatch; - while ((checklistMatch = checklistPattern.exec(content)) !== null) { - checklistPhases.add(checklistMatch[1]); - } - const detailPhases = new Set(phases.map(p => p.number)); - const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p)); - - const result = { - milestones, - phases, - phase_count: phases.length, - completed_phases: completedPhases, - total_plans: totalPlans, - total_summaries: totalSummaries, - progress_percent: totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0, - current_phase: currentPhase ? currentPhase.number : null, - next_phase: nextPhase ? nextPhase.number : null, - missing_phase_details: missingDetails.length > 0 ? missingDetails : null, - }; - - output(result, raw); -} - -// ─── Phase Add ──────────────────────────────────────────────────────────────── - -function cmdPhaseAdd(cwd, description, raw) { - if (!description) { - error('description required for phase add'); - } - - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - if (!fs.existsSync(roadmapPath)) { - error('ROADMAP.md not found'); - } - - const content = fs.readFileSync(roadmapPath, 'utf-8'); - const slug = generateSlugInternal(description); - - // Find highest integer phase number - const phasePattern = /#{2,4}\s*Phase\s+(\d+)(?:\.\d+)?:/gi; - let maxPhase = 0; - let m; - while ((m = phasePattern.exec(content)) !== null) { - const num = parseInt(m[1], 10); - if (num > maxPhase) maxPhase = num; - } - - const newPhaseNum = maxPhase + 1; - const paddedNum = String(newPhaseNum).padStart(2, '0'); - const dirName = `${paddedNum}-${slug}`; - const dirPath = path.join(cwd, '.planning', 'phases', dirName); - - // Create directory with .gitkeep so git tracks empty folders - fs.mkdirSync(dirPath, { recursive: true }); - fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); - - // Build phase entry - const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseNum} to break down)\n`; - - // Find insertion point: before last "---" or at end - let updatedContent; - const lastSeparator = content.lastIndexOf('\n---'); - if (lastSeparator > 0) { - updatedContent = content.slice(0, lastSeparator) + phaseEntry + content.slice(lastSeparator); - } else { - updatedContent = content + phaseEntry; - } - - fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); - - const result = { - phase_number: newPhaseNum, - padded: paddedNum, - name: description, - slug, - directory: `.planning/phases/${dirName}`, - }; - - output(result, raw, paddedNum); -} - -// ─── Phase Insert (Decimal) ────────────────────────────────────────────────── - -function cmdPhaseInsert(cwd, afterPhase, description, raw) { - if (!afterPhase || !description) { - error('after-phase and description required for phase insert'); - } - - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - if (!fs.existsSync(roadmapPath)) { - error('ROADMAP.md not found'); - } - - const content = fs.readFileSync(roadmapPath, 'utf-8'); - const slug = generateSlugInternal(description); - - // Normalize input then strip leading zeros for flexible matching - const normalizedAfter = normalizePhaseName(afterPhase); - const unpadded = normalizedAfter.replace(/^0+/, ''); - const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); - const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); - if (!targetPattern.test(content)) { - error(`Phase ${afterPhase} not found in ROADMAP.md`); - } - - // Calculate next decimal using existing logic - const phasesDir = path.join(cwd, '.planning', 'phases'); - const normalizedBase = normalizePhaseName(afterPhase); - let existingDecimals = []; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`); - for (const dir of dirs) { - const dm = dir.match(decimalPattern); - if (dm) existingDecimals.push(parseInt(dm[1], 10)); - } - } catch {} - - const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; - const decimalPhase = `${normalizedBase}.${nextDecimal}`; - const dirName = `${decimalPhase}-${slug}`; - const dirPath = path.join(cwd, '.planning', 'phases', dirName); - - // Create directory with .gitkeep so git tracks empty folders - fs.mkdirSync(dirPath, { recursive: true }); - fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); - - // Build phase entry - const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`; - - // Insert after the target phase section - const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); - const headerMatch = content.match(headerPattern); - if (!headerMatch) { - error(`Could not find Phase ${afterPhase} header`); - } - - const headerIdx = content.indexOf(headerMatch[0]); - const afterHeader = content.slice(headerIdx + headerMatch[0].length); - const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i); - - let insertIdx; - if (nextPhaseMatch) { - insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index; - } else { - insertIdx = content.length; - } - - const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx); - fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); - - const result = { - phase_number: decimalPhase, - after_phase: afterPhase, - name: description, - slug, - directory: `.planning/phases/${dirName}`, - }; - - output(result, raw, decimalPhase); -} - -// ─── Phase Remove ───────────────────────────────────────────────────────────── - -function cmdPhaseRemove(cwd, targetPhase, options, raw) { - if (!targetPhase) { - error('phase number required for phase remove'); - } - - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const phasesDir = path.join(cwd, '.planning', 'phases'); - const force = options.force || false; - - if (!fs.existsSync(roadmapPath)) { - error('ROADMAP.md not found'); - } - - // Normalize the target - const normalized = normalizePhaseName(targetPhase); - const isDecimal = targetPhase.includes('.'); - - // Find and validate target directory - let targetDir = null; - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized); - } catch {} - - // Check for executed work (SUMMARY.md files) - if (targetDir && !force) { - const targetPath = path.join(phasesDir, targetDir); - const files = fs.readdirSync(targetPath); - const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - if (summaries.length > 0) { - error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`); - } - } - - // Delete target directory - if (targetDir) { - fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true }); - } - - // Renumber subsequent phases - const renamedDirs = []; - const renamedFiles = []; - - if (isDecimal) { - // Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2) - const baseParts = normalized.split('.'); - const baseInt = baseParts[0]; - const removedDecimal = parseInt(baseParts[1], 10); - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - - // Find sibling decimals with higher numbers - const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); - const toRename = []; - for (const dir of dirs) { - const dm = dir.match(decPattern); - if (dm && parseInt(dm[1], 10) > removedDecimal) { - toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] }); - } - } - - // Sort descending to avoid conflicts - toRename.sort((a, b) => b.oldDecimal - a.oldDecimal); - - for (const item of toRename) { - const newDecimal = item.oldDecimal - 1; - const oldPhaseId = `${baseInt}.${item.oldDecimal}`; - const newPhaseId = `${baseInt}.${newDecimal}`; - const newDirName = `${baseInt}.${newDecimal}-${item.slug}`; - - // Rename directory - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); - renamedDirs.push({ from: item.dir, to: newDirName }); - - // Rename files inside - const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); - for (const f of dirFiles) { - // Files may have phase prefix like "06.2-01-PLAN.md" - if (f.includes(oldPhaseId)) { - const newFileName = f.replace(oldPhaseId, newPhaseId); - fs.renameSync( - path.join(phasesDir, newDirName, f), - path.join(phasesDir, newDirName, newFileName) - ); - renamedFiles.push({ from: f, to: newFileName }); - } - } - } - } catch {} - - } else { - // Integer removal: renumber all subsequent integer phases - const removedInt = parseInt(normalized, 10); - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - - // Collect directories that need renumbering (integer phases > removed, and their decimals) - const toRename = []; - for (const dir of dirs) { - const dm = dir.match(/^(\d+)(?:\.(\d+))?-(.+)$/); - if (!dm) continue; - const dirInt = parseInt(dm[1], 10); - if (dirInt > removedInt) { - toRename.push({ - dir, - oldInt: dirInt, - decimal: dm[2] ? parseInt(dm[2], 10) : null, - slug: dm[3], - }); - } - } - - // Sort descending to avoid conflicts - toRename.sort((a, b) => { - if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt; - return (b.decimal || 0) - (a.decimal || 0); - }); - - for (const item of toRename) { - const newInt = item.oldInt - 1; - const newPadded = String(newInt).padStart(2, '0'); - const oldPadded = String(item.oldInt).padStart(2, '0'); - const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : ''; - const oldPrefix = `${oldPadded}${decimalSuffix}`; - const newPrefix = `${newPadded}${decimalSuffix}`; - const newDirName = `${newPrefix}-${item.slug}`; - - // Rename directory - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); - renamedDirs.push({ from: item.dir, to: newDirName }); - - // Rename files inside - const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); - for (const f of dirFiles) { - if (f.startsWith(oldPrefix)) { - const newFileName = newPrefix + f.slice(oldPrefix.length); - fs.renameSync( - path.join(phasesDir, newDirName, f), - path.join(phasesDir, newDirName, newFileName) - ); - renamedFiles.push({ from: f, to: newFileName }); - } - } - } - } catch {} - } - - // Update ROADMAP.md - let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - - // Remove the target phase section - const targetEscaped = targetPhase.replace(/\./g, '\\.'); - const sectionPattern = new RegExp( - `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, - 'i' - ); - roadmapContent = roadmapContent.replace(sectionPattern, ''); - - // Remove from phase list (checkbox) - const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi'); - roadmapContent = roadmapContent.replace(checkboxPattern, ''); - - // Remove from progress table - const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'); - roadmapContent = roadmapContent.replace(tableRowPattern, ''); - - // Renumber references in ROADMAP for subsequent phases - if (!isDecimal) { - const removedInt = parseInt(normalized, 10); - - // Collect all integer phases > removedInt - const maxPhase = 99; // reasonable upper bound - for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) { - const newNum = oldNum - 1; - const oldStr = String(oldNum); - const newStr = String(newNum); - const oldPad = oldStr.padStart(2, '0'); - const newPad = newStr.padStart(2, '0'); - - // Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17: - roadmapContent = roadmapContent.replace( - new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), - `$1${newStr}$2` - ); - - // Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:** - roadmapContent = roadmapContent.replace( - new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), - `$1${newStr}$2` - ); - - // Plan references: 18-01 → 17-01 - roadmapContent = roadmapContent.replace( - new RegExp(`${oldPad}-(\\d{2})`, 'g'), - `${newPad}-$1` - ); - - // Table rows: | 18. → | 17. - roadmapContent = roadmapContent.replace( - new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), - `$1${newStr}. ` - ); - - // Depends on references - roadmapContent = roadmapContent.replace( - new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), - `$1${newStr}` - ); - } - } - - fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); - - // Update STATE.md phase count - const statePath = path.join(cwd, '.planning', 'STATE.md'); - if (fs.existsSync(statePath)) { - let stateContent = fs.readFileSync(statePath, 'utf-8'); - // Update "Total Phases" field - const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/; - const totalMatch = stateContent.match(totalPattern); - if (totalMatch) { - const oldTotal = parseInt(totalMatch[2], 10); - stateContent = stateContent.replace(totalPattern, `$1${oldTotal - 1}`); - } - // Update "Phase: X of Y" pattern - const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i; - const ofMatch = stateContent.match(ofPattern); - if (ofMatch) { - const oldTotal = parseInt(ofMatch[2], 10); - stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`); - } - fs.writeFileSync(statePath, stateContent, 'utf-8'); - } - - const result = { - removed: targetPhase, - directory_deleted: targetDir || null, - renamed_directories: renamedDirs, - renamed_files: renamedFiles, - roadmap_updated: true, - state_updated: fs.existsSync(statePath), - }; - - output(result, raw); -} - -// ─── Roadmap Update Plan Progress ──────────────────────────────────────────── - -function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { - if (!phaseNum) { - error('phase number required for roadmap update-plan-progress'); - } - - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - - const phaseInfo = findPhaseInternal(cwd, phaseNum); - if (!phaseInfo) { - error(`Phase ${phaseNum} not found`); - } - - const planCount = phaseInfo.plans.length; - const summaryCount = phaseInfo.summaries.length; - - if (planCount === 0) { - output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans'); - return; - } - - const isComplete = summaryCount >= planCount; - const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned'; - const today = new Date().toISOString().split('T')[0]; - - if (!fs.existsSync(roadmapPath)) { - output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap'); - return; - } - - let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - const phaseEscaped = phaseNum.replace('.', '\\.'); - - // Progress table row: update Plans column (summaries/plans) and Status column - const tablePattern = new RegExp( - `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`, - 'i' - ); - const dateField = isComplete ? ` ${today} ` : ' '; - roadmapContent = roadmapContent.replace( - tablePattern, - `$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4` - ); - - // Update plan count in phase detail section - const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, - 'i' - ); - const planCountText = isComplete - ? `${summaryCount}/${planCount} plans complete` - : `${summaryCount}/${planCount} plans executed`; - roadmapContent = roadmapContent.replace(planCountPattern, `$1${planCountText}`); - - // If complete: check checkbox - if (isComplete) { - const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, - 'i' - ); - roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`); - } - - fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); - - output({ - updated: true, - phase: phaseNum, - plan_count: planCount, - summary_count: summaryCount, - status, - complete: isComplete, - }, raw, `${summaryCount}/${planCount} ${status}`); -} - -// ─── Requirements Mark Complete ─────────────────────────────────────────────── - -function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) { - if (!reqIdsRaw || reqIdsRaw.length === 0) { - error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02'); - } - - // Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02] - const reqIds = reqIdsRaw - .join(' ') - .replace(/[\[\]]/g, '') - .split(/[,\s]+/) - .map(r => r.trim()) - .filter(Boolean); - - if (reqIds.length === 0) { - error('no valid requirement IDs found'); - } - - const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); - if (!fs.existsSync(reqPath)) { - output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file'); - return; - } - - let reqContent = fs.readFileSync(reqPath, 'utf-8'); - const updated = []; - const notFound = []; - - for (const reqId of reqIds) { - let found = false; - - // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID** - const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi'); - if (checkboxPattern.test(reqContent)) { - reqContent = reqContent.replace(checkboxPattern, '$1x$2'); - found = true; - } - - // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete | - const tablePattern = new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'); - if (tablePattern.test(reqContent)) { - // Re-read since test() advances lastIndex for global regex - reqContent = reqContent.replace( - new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'), - '$1 Complete $2' - ); - found = true; - } - - if (found) { - updated.push(reqId); - } else { - notFound.push(reqId); - } - } - - if (updated.length > 0) { - fs.writeFileSync(reqPath, reqContent, 'utf-8'); - } - - output({ - updated: updated.length > 0, - marked_complete: updated, - not_found: notFound, - total: reqIds.length, - }, raw, `${updated.length}/${reqIds.length} requirements marked complete`); -} - -// ─── Phase Complete (Transition) ────────────────────────────────────────────── - -function cmdPhaseComplete(cwd, phaseNum, raw) { - if (!phaseNum) { - error('phase number required for phase complete'); - } - - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const statePath = path.join(cwd, '.planning', 'STATE.md'); - const phasesDir = path.join(cwd, '.planning', 'phases'); - const normalized = normalizePhaseName(phaseNum); - const today = new Date().toISOString().split('T')[0]; - - // Verify phase info - const phaseInfo = findPhaseInternal(cwd, phaseNum); - if (!phaseInfo) { - error(`Phase ${phaseNum} not found`); - } - - const planCount = phaseInfo.plans.length; - const summaryCount = phaseInfo.summaries.length; - - // Update ROADMAP.md: mark phase complete - if (fs.existsSync(roadmapPath)) { - let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - - // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE) - const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}[:\\s][^\\n]*)`, - 'i' - ); - roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`); - - // Progress table: update Status to Complete, add date - const phaseEscaped = phaseNum.replace('.', '\\.'); - const tablePattern = new RegExp( - `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`, - 'i' - ); - roadmapContent = roadmapContent.replace( - tablePattern, - `$1 Complete $2 ${today} $3` - ); - - // Update plan count in phase section - const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, - 'i' - ); - roadmapContent = roadmapContent.replace( - planCountPattern, - `$1${summaryCount}/${planCount} plans complete` - ); - - fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); - - // Update REQUIREMENTS.md traceability for this phase's requirements - const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); - if (fs.existsSync(reqPath)) { - // Extract Requirements line from roadmap for this phase - const reqMatch = roadmapContent.match( - new RegExp(`Phase\\s+${phaseNum.replace('.', '\\.')}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i') - ); - - if (reqMatch) { - const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean); - let reqContent = fs.readFileSync(reqPath, 'utf-8'); - - for (const reqId of reqIds) { - // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID** - reqContent = reqContent.replace( - new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi'), - '$1x$2' - ); - // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete | - reqContent = reqContent.replace( - new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'), - '$1 Complete $2' - ); - } - - fs.writeFileSync(reqPath, reqContent, 'utf-8'); - } - } - } - - // Find next phase - let nextPhaseNum = null; - let nextPhaseName = null; - let isLastPhase = true; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - const currentFloat = parseFloat(phaseNum); - - // Find the next phase directory after current - for (const dir of dirs) { - const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); - if (dm) { - const dirFloat = parseFloat(dm[1]); - if (dirFloat > currentFloat) { - nextPhaseNum = dm[1]; - nextPhaseName = dm[2] || null; - isLastPhase = false; - break; - } - } - } - } catch {} - - // Update STATE.md - if (fs.existsSync(statePath)) { - let stateContent = fs.readFileSync(statePath, 'utf-8'); - - // Update Current Phase - stateContent = stateContent.replace( - /(\*\*Current Phase:\*\*\s*).*/, - `$1${nextPhaseNum || phaseNum}` - ); - - // Update Current Phase Name - if (nextPhaseName) { - stateContent = stateContent.replace( - /(\*\*Current Phase Name:\*\*\s*).*/, - `$1${nextPhaseName.replace(/-/g, ' ')}` - ); - } - - // Update Status - stateContent = stateContent.replace( - /(\*\*Status:\*\*\s*).*/, - `$1${isLastPhase ? 'Milestone complete' : 'Ready to plan'}` - ); - - // Update Current Plan - stateContent = stateContent.replace( - /(\*\*Current Plan:\*\*\s*).*/, - `$1Not started` - ); - - // Update Last Activity - stateContent = stateContent.replace( - /(\*\*Last Activity:\*\*\s*).*/, - `$1${today}` - ); - - // Update Last Activity Description - stateContent = stateContent.replace( - /(\*\*Last Activity Description:\*\*\s*).*/, - `$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}` - ); - - fs.writeFileSync(statePath, stateContent, 'utf-8'); - } - - const result = { - completed_phase: phaseNum, - phase_name: phaseInfo.phase_name, - plans_executed: `${summaryCount}/${planCount}`, - next_phase: nextPhaseNum, - next_phase_name: nextPhaseName, - is_last_phase: isLastPhase, - date: today, - roadmap_updated: fs.existsSync(roadmapPath), - state_updated: fs.existsSync(statePath), - }; - - output(result, raw); -} - -// ─── Milestone Complete ─────────────────────────────────────────────────────── - -function cmdMilestoneComplete(cwd, version, options, raw) { - if (!version) { - error('version required for milestone complete (e.g., v1.0)'); - } - - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); - const statePath = path.join(cwd, '.planning', 'STATE.md'); - const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md'); - const archiveDir = path.join(cwd, '.planning', 'milestones'); - const phasesDir = path.join(cwd, '.planning', 'phases'); - const today = new Date().toISOString().split('T')[0]; - const milestoneName = options.name || version; - - // Ensure archive directory exists - fs.mkdirSync(archiveDir, { recursive: true }); - - // Gather stats from phases - let phaseCount = 0; - let totalPlans = 0; - let totalTasks = 0; - const accomplishments = []; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - - for (const dir of dirs) { - phaseCount++; - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - totalPlans += plans.length; - - // Extract one-liners from summaries - for (const s of summaries) { - try { - const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8'); - const fm = extractFrontmatter(content); - if (fm['one-liner']) { - accomplishments.push(fm['one-liner']); - } - // Count tasks - const taskMatches = content.match(/##\s*Task\s*\d+/gi) || []; - totalTasks += taskMatches.length; - } catch {} - } - } - } catch {} - - // Archive ROADMAP.md - if (fs.existsSync(roadmapPath)) { - const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8'); - } - - // Archive REQUIREMENTS.md - if (fs.existsSync(reqPath)) { - const reqContent = fs.readFileSync(reqPath, 'utf-8'); - const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`; - fs.writeFileSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8'); - } - - // Archive audit file if exists - const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`); - if (fs.existsSync(auditFile)) { - fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)); - } - - // Create/append MILESTONES.md entry - const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n'); - const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`; - - if (fs.existsSync(milestonesPath)) { - const existing = fs.readFileSync(milestonesPath, 'utf-8'); - fs.writeFileSync(milestonesPath, existing + '\n' + milestoneEntry, 'utf-8'); - } else { - fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8'); - } - - // Update STATE.md - if (fs.existsSync(statePath)) { - let stateContent = fs.readFileSync(statePath, 'utf-8'); - stateContent = stateContent.replace( - /(\*\*Status:\*\*\s*).*/, - `$1${version} milestone complete` - ); - stateContent = stateContent.replace( - /(\*\*Last Activity:\*\*\s*).*/, - `$1${today}` - ); - stateContent = stateContent.replace( - /(\*\*Last Activity Description:\*\*\s*).*/, - `$1${version} milestone completed and archived` - ); - fs.writeFileSync(statePath, stateContent, 'utf-8'); - } - - // Archive phase directories if requested - let phasesArchived = false; - if (options.archivePhases) { - try { - const phaseArchiveDir = path.join(archiveDir, `${version}-phases`); - fs.mkdirSync(phaseArchiveDir, { recursive: true }); - - const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name); - for (const dir of phaseDirNames) { - fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir)); - } - phasesArchived = phaseDirNames.length > 0; - } catch {} - } - - const result = { - version, - name: milestoneName, - date: today, - phases: phaseCount, - plans: totalPlans, - tasks: totalTasks, - accomplishments, - archived: { - roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)), - requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)), - audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)), - phases: phasesArchived, - }, - milestones_updated: true, - state_updated: fs.existsSync(statePath), - }; - - output(result, raw); -} - -// ─── Validate Consistency ───────────────────────────────────────────────────── - -function cmdValidateConsistency(cwd, raw) { - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const phasesDir = path.join(cwd, '.planning', 'phases'); - const errors = []; - const warnings = []; - - // Check for ROADMAP - if (!fs.existsSync(roadmapPath)) { - errors.push('ROADMAP.md not found'); - output({ passed: false, errors, warnings }, raw, 'failed'); - return; - } - - const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - - // Extract phases from ROADMAP - const roadmapPhases = new Set(); - const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi; - let m; - while ((m = phasePattern.exec(roadmapContent)) !== null) { - roadmapPhases.add(m[1]); - } - - // Get phases on disk - const diskPhases = new Set(); - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - for (const dir of dirs) { - const dm = dir.match(/^(\d+(?:\.\d+)?)/); - if (dm) diskPhases.add(dm[1]); - } - } catch {} - - // Check: phases in ROADMAP but not on disk - for (const p of roadmapPhases) { - if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) { - warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`); - } - } - - // Check: phases on disk but not in ROADMAP - for (const p of diskPhases) { - const unpadded = String(parseInt(p, 10)); - if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) { - warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`); - } - } - - // Check: sequential phase numbers (integers only) - const integerPhases = [...diskPhases] - .filter(p => !p.includes('.')) - .map(p => parseInt(p, 10)) - .sort((a, b) => a - b); - - for (let i = 1; i < integerPhases.length; i++) { - if (integerPhases[i] !== integerPhases[i - 1] + 1) { - warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`); - } - } - - // Check: plan numbering within phases - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - - for (const dir of dirs) { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); - - // Extract plan numbers - const planNums = plans.map(p => { - const pm = p.match(/-(\d{2})-PLAN\.md$/); - return pm ? parseInt(pm[1], 10) : null; - }).filter(n => n !== null); - - for (let i = 1; i < planNums.length; i++) { - if (planNums[i] !== planNums[i - 1] + 1) { - warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} → ${planNums[i]}`); - } - } - - // Check: plans without summaries (completed plans) - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); - const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); - const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); - - // Summary without matching plan is suspicious - for (const sid of summaryIds) { - if (!planIds.has(sid)) { - warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`); - } - } - } - } catch {} - - // Check: frontmatter in plans has required fields - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - - for (const dir of dirs) { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')); - - for (const plan of plans) { - const content = fs.readFileSync(path.join(phasesDir, dir, plan), 'utf-8'); - const fm = extractFrontmatter(content); - - if (!fm.wave) { - warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`); - } - } - } - } catch {} - - const passed = errors.length === 0; - output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed'); -} - -// ─── Validate Health ────────────────────────────────────────────────────────── - -function cmdValidateHealth(cwd, options, raw) { - const planningDir = path.join(cwd, '.planning'); - const projectPath = path.join(planningDir, 'PROJECT.md'); - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - const statePath = path.join(planningDir, 'STATE.md'); - const configPath = path.join(planningDir, 'config.json'); - const phasesDir = path.join(planningDir, 'phases'); - - const errors = []; - const warnings = []; - const info = []; - const repairs = []; - - // Helper to add issue - const addIssue = (severity, code, message, fix, repairable = false) => { - const issue = { code, message, fix, repairable }; - if (severity === 'error') errors.push(issue); - else if (severity === 'warning') warnings.push(issue); - else info.push(issue); - }; - - // ─── Check 1: .planning/ exists ─────────────────────────────────────────── - if (!fs.existsSync(planningDir)) { - addIssue('error', 'E001', '.planning/ directory not found', 'Run /gsd:new-project to initialize'); - output({ - status: 'broken', - errors, - warnings, - info, - repairable_count: 0, - }, raw); - return; - } - - // ─── Check 2: PROJECT.md exists and has required sections ───────────────── - if (!fs.existsSync(projectPath)) { - addIssue('error', 'E002', 'PROJECT.md not found', 'Run /gsd:new-project to create'); - } else { - const content = fs.readFileSync(projectPath, 'utf-8'); - const requiredSections = ['## What This Is', '## Core Value', '## Requirements']; - for (const section of requiredSections) { - if (!content.includes(section)) { - addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually'); - } - } - } - - // ─── Check 3: ROADMAP.md exists ─────────────────────────────────────────── - if (!fs.existsSync(roadmapPath)) { - addIssue('error', 'E003', 'ROADMAP.md not found', 'Run /gsd:new-milestone to create roadmap'); - } - - // ─── Check 4: STATE.md exists and references valid phases ───────────────── - if (!fs.existsSync(statePath)) { - addIssue('error', 'E004', 'STATE.md not found', 'Run /gsd:health --repair to regenerate', true); - repairs.push('regenerateState'); - } else { - const stateContent = fs.readFileSync(statePath, 'utf-8'); - // Extract phase references from STATE.md - const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)?)/g)].map(m => m[1]); - // Get disk phases - const diskPhases = new Set(); - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory()) { - const m = e.name.match(/^(\d+(?:\.\d+)?)/); - if (m) diskPhases.add(m[1]); - } - } - } catch {} - // Check for invalid references - for (const ref of phaseRefs) { - const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0'); - if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) { - // Only warn if phases dir has any content (not just an empty project) - if (diskPhases.size > 0) { - addIssue('warning', 'W002', `STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`, 'Run /gsd:health --repair to regenerate STATE.md', true); - if (!repairs.includes('regenerateState')) repairs.push('regenerateState'); - } - } - } - } - - // ─── Check 5: config.json valid JSON + valid schema ─────────────────────── - if (!fs.existsSync(configPath)) { - addIssue('warning', 'W003', 'config.json not found', 'Run /gsd:health --repair to create with defaults', true); - repairs.push('createConfig'); - } else { - try { - const raw = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw); - // Validate known fields - const validProfiles = ['quality', 'balanced', 'budget']; - if (parsed.model_profile && !validProfiles.includes(parsed.model_profile)) { - addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed.model_profile}"`, `Valid values: ${validProfiles.join(', ')}`); - } - } catch (err) { - addIssue('error', 'E005', `config.json: JSON parse error - ${err.message}`, 'Run /gsd:health --repair to reset to defaults', true); - repairs.push('resetConfig'); - } - } - - // ─── Check 6: Phase directory naming (NN-name format) ───────────────────── - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)?-[\w-]+$/)) { - addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)'); - } - } - } catch {} - - // ─── Check 7: Orphaned plans (PLAN without SUMMARY) ─────────────────────── - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - for (const e of entries) { - if (!e.isDirectory()) continue; - const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name)); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))); - - for (const plan of plans) { - const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', ''); - if (!summaryBases.has(planBase)) { - addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress'); - } - } - } - } catch {} - - // ─── Check 8: Run existing consistency checks ───────────────────────────── - // Inline subset of cmdValidateConsistency - if (fs.existsSync(roadmapPath)) { - const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - const roadmapPhases = new Set(); - const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi; - let m; - while ((m = phasePattern.exec(roadmapContent)) !== null) { - roadmapPhases.add(m[1]); - } - - const diskPhases = new Set(); - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory()) { - const dm = e.name.match(/^(\d+(?:\.\d+)?)/); - if (dm) diskPhases.add(dm[1]); - } - } - } catch {} - - // Phases in ROADMAP but not on disk - for (const p of roadmapPhases) { - const padded = String(parseInt(p, 10)).padStart(2, '0'); - if (!diskPhases.has(p) && !diskPhases.has(padded)) { - addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap'); - } - } - - // Phases on disk but not in ROADMAP - for (const p of diskPhases) { - const unpadded = String(parseInt(p, 10)); - if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) { - addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory'); - } - } - } - - // ─── Perform repairs if requested ───────────────────────────────────────── - const repairActions = []; - if (options.repair && repairs.length > 0) { - for (const repair of repairs) { - try { - switch (repair) { - case 'createConfig': - case 'resetConfig': { - const defaults = { - model_profile: 'balanced', - commit_docs: true, - search_gitignored: false, - branching_strategy: 'none', - research: true, - plan_checker: true, - verifier: true, - parallelization: true, - }; - fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); - repairActions.push({ action: repair, success: true, path: 'config.json' }); - break; - } - case 'regenerateState': { - // Create timestamped backup before overwriting - if (fs.existsSync(statePath)) { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const backupPath = `${statePath}.bak-${timestamp}`; - fs.copyFileSync(statePath, backupPath); - repairActions.push({ action: 'backupState', success: true, path: backupPath }); - } - // Generate minimal STATE.md from ROADMAP.md structure - const milestone = getMilestoneInfo(cwd); - let stateContent = `# Session State\n\n`; - stateContent += `## Project Reference\n\n`; - stateContent += `See: .planning/PROJECT.md\n\n`; - stateContent += `## Position\n\n`; - stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`; - stateContent += `**Current phase:** (determining...)\n`; - stateContent += `**Status:** Resuming\n\n`; - stateContent += `## Session Log\n\n`; - stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by /gsd:health --repair\n`; - fs.writeFileSync(statePath, stateContent, 'utf-8'); - repairActions.push({ action: repair, success: true, path: 'STATE.md' }); - break; - } - } - } catch (err) { - repairActions.push({ action: repair, success: false, error: err.message }); - } - } - } - - // ─── Determine overall status ───────────────────────────────────────────── - let status; - if (errors.length > 0) { - status = 'broken'; - } else if (warnings.length > 0) { - status = 'degraded'; - } else { - status = 'healthy'; - } - - const repairableCount = errors.filter(e => e.repairable).length + - warnings.filter(w => w.repairable).length; - - output({ - status, - errors, - warnings, - info, - repairable_count: repairableCount, - repairs_performed: repairActions.length > 0 ? repairActions : undefined, - }, raw); -} - -// ─── Progress Render ────────────────────────────────────────────────────────── - -function cmdProgressRender(cwd, format, raw) { - const phasesDir = path.join(cwd, '.planning', 'phases'); - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const milestone = getMilestoneInfo(cwd); - - const phases = []; - let totalPlans = 0; - let totalSummaries = 0; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => { - const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); - const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); - return aNum - bNum; - }); - - for (const dir of dirs) { - const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); - const phaseNum = dm ? dm[1] : dir; - const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; - - totalPlans += plans; - totalSummaries += summaries; - - let status; - if (plans === 0) status = 'Pending'; - else if (summaries >= plans) status = 'Complete'; - else if (summaries > 0) status = 'In Progress'; - else status = 'Planned'; - - phases.push({ number: phaseNum, name: phaseName, plans, summaries, status }); - } - } catch {} - - const percent = totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0; - - if (format === 'table') { - // Render markdown table - const barWidth = 10; - const filled = Math.round((percent / 100) * barWidth); - const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); - let out = `# ${milestone.version} ${milestone.name}\n\n`; - out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`; - out += `| Phase | Name | Plans | Status |\n`; - out += `|-------|------|-------|--------|\n`; - for (const p of phases) { - out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`; - } - output({ rendered: out }, raw, out); - } else if (format === 'bar') { - const barWidth = 20; - const filled = Math.round((percent / 100) * barWidth); - const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); - const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`; - output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text); - } else { - // JSON format - output({ - milestone_version: milestone.version, - milestone_name: milestone.name, - phases, - total_plans: totalPlans, - total_summaries: totalSummaries, - percent, - }, raw); - } -} - -// ─── Todo Complete ──────────────────────────────────────────────────────────── - -function cmdTodoComplete(cwd, filename, raw) { - if (!filename) { - error('filename required for todo complete'); - } - - const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); - const completedDir = path.join(cwd, '.planning', 'todos', 'completed'); - const sourcePath = path.join(pendingDir, filename); - - if (!fs.existsSync(sourcePath)) { - error(`Todo not found: ${filename}`); - } - - // Ensure completed directory exists - fs.mkdirSync(completedDir, { recursive: true }); - - // Read, add completion timestamp, move - let content = fs.readFileSync(sourcePath, 'utf-8'); - const today = new Date().toISOString().split('T')[0]; - content = `completed: ${today}\n` + content; - - fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8'); - fs.unlinkSync(sourcePath); - - output({ completed: true, file: filename, date: today }, raw, 'completed'); -} - -// ─── Scaffold ───────────────────────────────────────────────────────────────── - -function cmdScaffold(cwd, type, options, raw) { - const { phase, name } = options; - const padded = phase ? normalizePhaseName(phase) : '00'; - const today = new Date().toISOString().split('T')[0]; - - // Find phase directory - const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null; - const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null; - - if (phase && !phaseDir && type !== 'phase-dir') { - error(`Phase ${phase} directory not found`); - } - - let filePath, content; - - switch (type) { - case 'context': { - filePath = path.join(phaseDir, `${padded}-CONTEXT.md`); - content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Context\n\n## Decisions\n\n_Decisions will be captured during /gsd:discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`; - break; - } - case 'uat': { - filePath = path.join(phaseDir, `${padded}-UAT.md`); - content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`; - break; - } - case 'verification': { - filePath = path.join(phaseDir, `${padded}-VERIFICATION.md`); - content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`; - break; - } - case 'phase-dir': { - if (!phase || !name) { - error('phase and name required for phase-dir scaffold'); - } - const slug = generateSlugInternal(name); - const dirName = `${padded}-${slug}`; - const phasesParent = path.join(cwd, '.planning', 'phases'); - fs.mkdirSync(phasesParent, { recursive: true }); - const dirPath = path.join(phasesParent, dirName); - fs.mkdirSync(dirPath, { recursive: true }); - output({ created: true, directory: `.planning/phases/${dirName}`, path: dirPath }, raw, dirPath); - return; - } - default: - error(`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`); - } - - if (fs.existsSync(filePath)) { - output({ created: false, reason: 'already_exists', path: filePath }, raw, 'exists'); - return; - } - - fs.writeFileSync(filePath, content, 'utf-8'); - const relPath = path.relative(cwd, filePath); - output({ created: true, path: relPath }, raw, relPath); -} - -// ─── Compound Commands ──────────────────────────────────────────────────────── - -function resolveModelInternal(cwd, agentType) { - const config = loadConfig(cwd); - - // Check per-agent override first - const override = config.model_overrides?.[agentType]; - if (override) { - return override === 'opus' ? 'inherit' : override; - } - - // Fall back to profile lookup - const profile = config.model_profile || 'balanced'; - const agentModels = MODEL_PROFILES[agentType]; - if (!agentModels) return 'sonnet'; - const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet'; - return resolved === 'opus' ? 'inherit' : resolved; -} - -function getArchivedPhaseDirs(cwd) { - const milestonesDir = path.join(cwd, '.planning', 'milestones'); - const results = []; - - if (!fs.existsSync(milestonesDir)) return results; - - try { - const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); - // Find v*-phases directories, sort newest first - const phaseDirs = milestoneEntries - .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) - .map(e => e.name) - .sort() - .reverse(); - - for (const archiveName of phaseDirs) { - const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; - const archivePath = path.join(milestonesDir, archiveName); - const entries = fs.readdirSync(archivePath, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - - for (const dir of dirs) { - results.push({ - name: dir, - milestone: version, - basePath: path.join('.planning', 'milestones', archiveName), - fullPath: path.join(archivePath, dir), - }); - } - } - } catch {} - - return results; -} - -function searchPhaseInDir(baseDir, relBase, normalized) { - try { - const entries = fs.readdirSync(baseDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - const match = dirs.find(d => d.startsWith(normalized)); - if (!match) return null; - - const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/); - const phaseNumber = dirMatch ? dirMatch[1] : normalized; - const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; - const phaseDir = path.join(baseDir, match); - const phaseFiles = fs.readdirSync(phaseDir); - - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); - const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - - const completedPlanIds = new Set( - summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) - ); - const incompletePlans = plans.filter(p => { - const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); - return !completedPlanIds.has(planId); - }); - - return { - found: true, - directory: path.join(relBase, match), - phase_number: phaseNumber, - phase_name: phaseName, - phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, - plans, - summaries, - incomplete_plans: incompletePlans, - has_research: hasResearch, - has_context: hasContext, - has_verification: hasVerification, - }; - } catch { - return null; - } -} - -function findPhaseInternal(cwd, phase) { - if (!phase) return null; - - const phasesDir = path.join(cwd, '.planning', 'phases'); - const normalized = normalizePhaseName(phase); - - // Search current phases first - const current = searchPhaseInDir(phasesDir, path.join('.planning', 'phases'), normalized); - if (current) return current; - - // Search archived milestone phases (newest first) - const milestonesDir = path.join(cwd, '.planning', 'milestones'); - if (!fs.existsSync(milestonesDir)) return null; - - try { - const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); - const archiveDirs = milestoneEntries - .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) - .map(e => e.name) - .sort() - .reverse(); - - for (const archiveName of archiveDirs) { - const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; - const archivePath = path.join(milestonesDir, archiveName); - const relBase = path.join('.planning', 'milestones', archiveName); - const result = searchPhaseInDir(archivePath, relBase, normalized); - if (result) { - result.archived = version; - return result; - } - } - } catch {} - - return null; -} - -function getRoadmapPhaseInternal(cwd, phaseNum) { - if (!phaseNum) return null; - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - if (!fs.existsSync(roadmapPath)) return null; - - try { - const content = fs.readFileSync(roadmapPath, 'utf-8'); - const escapedPhase = phaseNum.toString().replace(/\./g, '\\.'); - const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i'); - const headerMatch = content.match(phasePattern); - if (!headerMatch) return null; - - const phaseName = headerMatch[1].trim(); - const headerIndex = headerMatch.index; - const restOfContent = content.slice(headerIndex); - const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); - const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length; - const section = content.slice(headerIndex, sectionEnd).trim(); - - const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); - const goal = goalMatch ? goalMatch[1].trim() : null; - - return { - found: true, - phase_number: phaseNum.toString(), - phase_name: phaseName, - goal, - section, - }; - } catch { - return null; - } -} - -function pathExistsInternal(cwd, targetPath) { - const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); - try { - fs.statSync(fullPath); - return true; - } catch { - return false; - } -} - -function generateSlugInternal(text) { - if (!text) return null; - return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); -} - -function getMilestoneInfo(cwd) { - try { - const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'); - const versionMatch = roadmap.match(/v(\d+\.\d+)/); - const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/); - return { - version: versionMatch ? versionMatch[0] : 'v1.0', - name: nameMatch ? nameMatch[1].trim() : 'milestone', - }; - } catch { - return { version: 'v1.0', name: 'milestone' }; - } -} - -function cmdInitExecutePhase(cwd, phase, raw) { - if (!phase) { - error('phase required for init execute-phase'); - } - - const config = loadConfig(cwd); - const phaseInfo = findPhaseInternal(cwd, phase); - const milestone = getMilestoneInfo(cwd); - - const result = { - // Models - executor_model: resolveModelInternal(cwd, 'gsd-executor'), - verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), - - // Config flags - commit_docs: config.commit_docs, - parallelization: config.parallelization, - branching_strategy: config.branching_strategy, - phase_branch_template: config.phase_branch_template, - milestone_branch_template: config.milestone_branch_template, - verifier_enabled: config.verifier, - - // Phase info - phase_found: !!phaseInfo, - phase_dir: phaseInfo?.directory || null, - phase_number: phaseInfo?.phase_number || null, - phase_name: phaseInfo?.phase_name || null, - phase_slug: phaseInfo?.phase_slug || null, - - // Plan inventory - plans: phaseInfo?.plans || [], - summaries: phaseInfo?.summaries || [], - incomplete_plans: phaseInfo?.incomplete_plans || [], - plan_count: phaseInfo?.plans?.length || 0, - incomplete_count: phaseInfo?.incomplete_plans?.length || 0, - - // Branch name (pre-computed) - branch_name: config.branching_strategy === 'phase' && phaseInfo - ? config.phase_branch_template - .replace('{phase}', phaseInfo.phase_number) - .replace('{slug}', phaseInfo.phase_slug || 'phase') - : config.branching_strategy === 'milestone' - ? config.milestone_branch_template - .replace('{milestone}', milestone.version) - .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone') - : null, - - // Milestone info - milestone_version: milestone.version, - milestone_name: milestone.name, - milestone_slug: generateSlugInternal(milestone.name), - - // File existence - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - config_exists: pathExistsInternal(cwd, '.planning/config.json'), - // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - config_path: '.planning/config.json', - }; - - output(result, raw); -} - -function cmdInitPlanPhase(cwd, phase, raw) { - if (!phase) { - error('phase required for init plan-phase'); - } - - const config = loadConfig(cwd); - const phaseInfo = findPhaseInternal(cwd, phase); - - const result = { - // Models - researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'), - planner_model: resolveModelInternal(cwd, 'gsd-planner'), - checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), - - // Workflow flags - research_enabled: config.research, - plan_checker_enabled: config.plan_checker, - commit_docs: config.commit_docs, - - // Phase info - phase_found: !!phaseInfo, - phase_dir: phaseInfo?.directory || null, - phase_number: phaseInfo?.phase_number || null, - phase_name: phaseInfo?.phase_name || null, - phase_slug: phaseInfo?.phase_slug || null, - padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null, - - // Existing artifacts - has_research: phaseInfo?.has_research || false, - has_context: phaseInfo?.has_context || false, - has_plans: (phaseInfo?.plans?.length || 0) > 0, - plan_count: phaseInfo?.plans?.length || 0, - - // Environment - planning_exists: pathExistsInternal(cwd, '.planning'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - - // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - requirements_path: '.planning/REQUIREMENTS.md', - }; - - if (phaseInfo?.directory) { - // Find *-CONTEXT.md in phase directory - const phaseDirFull = path.join(cwd, phaseInfo.directory); - try { - const files = fs.readdirSync(phaseDirFull); - const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) { - result.context_path = path.join(phaseInfo.directory, contextFile); - } - const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) { - result.research_path = path.join(phaseInfo.directory, researchFile); - } - const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) { - result.verification_path = path.join(phaseInfo.directory, verificationFile); - } - const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) { - result.uat_path = path.join(phaseInfo.directory, uatFile); - } - } catch {} - } - - output(result, raw); -} - -function cmdInitNewProject(cwd, raw) { - const config = loadConfig(cwd); - - // Detect Brave Search API key availability - const homedir = require('os').homedir(); - const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); - const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); - - // Detect existing code - let hasCode = false; - let hasPackageFile = false; - try { - const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - hasCode = files.trim().length > 0; - } catch {} - - hasPackageFile = pathExistsInternal(cwd, 'package.json') || - pathExistsInternal(cwd, 'requirements.txt') || - pathExistsInternal(cwd, 'Cargo.toml') || - pathExistsInternal(cwd, 'go.mod') || - pathExistsInternal(cwd, 'Package.swift'); - - const result = { - // Models - researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), - synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), - roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), - - // Config - commit_docs: config.commit_docs, - - // Existing state - project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - has_codebase_map: pathExistsInternal(cwd, '.planning/codebase'), - planning_exists: pathExistsInternal(cwd, '.planning'), - - // Brownfield detection - has_existing_code: hasCode, - has_package_file: hasPackageFile, - is_brownfield: hasCode || hasPackageFile, - needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'), - - // Git state - has_git: pathExistsInternal(cwd, '.git'), - - // Enhanced search - brave_search_available: hasBraveSearch, - - // File paths - project_path: '.planning/PROJECT.md', - }; - - output(result, raw); -} - -function cmdInitNewMilestone(cwd, raw) { - const config = loadConfig(cwd); - const milestone = getMilestoneInfo(cwd); - - const result = { - // Models - researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), - synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), - roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), - - // Config - commit_docs: config.commit_docs, - research_enabled: config.research, - - // Current milestone - current_milestone: milestone.version, - current_milestone_name: milestone.name, - - // File existence - project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - - // File paths - project_path: '.planning/PROJECT.md', - roadmap_path: '.planning/ROADMAP.md', - state_path: '.planning/STATE.md', - }; - - output(result, raw); -} - -function cmdInitQuick(cwd, description, raw) { - const config = loadConfig(cwd); - const now = new Date(); - const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null; - - // Find next quick task number - const quickDir = path.join(cwd, '.planning', 'quick'); - let nextNum = 1; - try { - const existing = fs.readdirSync(quickDir) - .filter(f => /^\d+-/.test(f)) - .map(f => parseInt(f.split('-')[0], 10)) - .filter(n => !isNaN(n)); - if (existing.length > 0) { - nextNum = Math.max(...existing) + 1; - } - } catch {} - - const result = { - // Models - planner_model: resolveModelInternal(cwd, 'gsd-planner'), - executor_model: resolveModelInternal(cwd, 'gsd-executor'), - checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), - verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), - - // Config - commit_docs: config.commit_docs, - - // Quick task info - next_num: nextNum, - slug: slug, - description: description || null, - - // Timestamps - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - - // Paths - quick_dir: '.planning/quick', - task_dir: slug ? `.planning/quick/${nextNum}-${slug}` : null, - - // File existence - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - planning_exists: pathExistsInternal(cwd, '.planning'), - - }; - - output(result, raw); -} - -function cmdInitResume(cwd, raw) { - const config = loadConfig(cwd); - - // Check for interrupted agent - let interruptedAgentId = null; - try { - interruptedAgentId = fs.readFileSync(path.join(cwd, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); - } catch {} - - const result = { - // File existence - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - planning_exists: pathExistsInternal(cwd, '.planning'), - - // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - project_path: '.planning/PROJECT.md', - - // Agent state - has_interrupted_agent: !!interruptedAgentId, - interrupted_agent_id: interruptedAgentId, - - // Config - commit_docs: config.commit_docs, - }; - - output(result, raw); -} - -function cmdInitVerifyWork(cwd, phase, raw) { - if (!phase) { - error('phase required for init verify-work'); - } - - const config = loadConfig(cwd); - const phaseInfo = findPhaseInternal(cwd, phase); - - const result = { - // Models - planner_model: resolveModelInternal(cwd, 'gsd-planner'), - checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), - - // Config - commit_docs: config.commit_docs, - - // Phase info - phase_found: !!phaseInfo, - phase_dir: phaseInfo?.directory || null, - phase_number: phaseInfo?.phase_number || null, - phase_name: phaseInfo?.phase_name || null, - - // Existing artifacts - has_verification: phaseInfo?.has_verification || false, - }; - - output(result, raw); -} - -function cmdInitPhaseOp(cwd, phase, raw) { - const config = loadConfig(cwd); - let phaseInfo = findPhaseInternal(cwd, phase); - - // Fallback to ROADMAP.md if no directory exists (e.g., Plans: TBD) - if (!phaseInfo) { - const roadmapPhase = getRoadmapPhaseInternal(cwd, phase); - if (roadmapPhase?.found) { - const phaseName = roadmapPhase.phase_name; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - }; - } - } - - const result = { - // Config - commit_docs: config.commit_docs, - brave_search: config.brave_search, - - // Phase info - phase_found: !!phaseInfo, - phase_dir: phaseInfo?.directory || null, - phase_number: phaseInfo?.phase_number || null, - phase_name: phaseInfo?.phase_name || null, - phase_slug: phaseInfo?.phase_slug || null, - padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null, - - // Existing artifacts - has_research: phaseInfo?.has_research || false, - has_context: phaseInfo?.has_context || false, - has_plans: (phaseInfo?.plans?.length || 0) > 0, - has_verification: phaseInfo?.has_verification || false, - plan_count: phaseInfo?.plans?.length || 0, - - // File existence - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - planning_exists: pathExistsInternal(cwd, '.planning'), - - // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - requirements_path: '.planning/REQUIREMENTS.md', - }; - - if (phaseInfo?.directory) { - const phaseDirFull = path.join(cwd, phaseInfo.directory); - try { - const files = fs.readdirSync(phaseDirFull); - const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) { - result.context_path = path.join(phaseInfo.directory, contextFile); - } - const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) { - result.research_path = path.join(phaseInfo.directory, researchFile); - } - const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) { - result.verification_path = path.join(phaseInfo.directory, verificationFile); - } - const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) { - result.uat_path = path.join(phaseInfo.directory, uatFile); - } - } catch {} - } - - output(result, raw); -} - -function cmdInitTodos(cwd, area, raw) { - const config = loadConfig(cwd); - const now = new Date(); - - // List todos (reuse existing logic) - const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); - let count = 0; - const todos = []; - - try { - const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md')); - for (const file of files) { - try { - const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8'); - const createdMatch = content.match(/^created:\s*(.+)$/m); - const titleMatch = content.match(/^title:\s*(.+)$/m); - const areaMatch = content.match(/^area:\s*(.+)$/m); - const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; - - if (area && todoArea !== area) continue; - - count++; - todos.push({ - file, - created: createdMatch ? createdMatch[1].trim() : 'unknown', - title: titleMatch ? titleMatch[1].trim() : 'Untitled', - area: todoArea, - path: path.join('.planning', 'todos', 'pending', file), - }); - } catch {} - } - } catch {} - - const result = { - // Config - commit_docs: config.commit_docs, - - // Timestamps - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - - // Todo inventory - todo_count: count, - todos, - area_filter: area || null, - - // Paths - pending_dir: '.planning/todos/pending', - completed_dir: '.planning/todos/completed', - - // File existence - planning_exists: pathExistsInternal(cwd, '.planning'), - todos_dir_exists: pathExistsInternal(cwd, '.planning/todos'), - pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'), - }; - - output(result, raw); -} - -function cmdInitMilestoneOp(cwd, raw) { - const config = loadConfig(cwd); - const milestone = getMilestoneInfo(cwd); - - // Count phases - let phaseCount = 0; - let completedPhases = 0; - const phasesDir = path.join(cwd, '.planning', 'phases'); - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - phaseCount = dirs.length; - - // Count phases with summaries (completed) - for (const dir of dirs) { - try { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - if (hasSummary) completedPhases++; - } catch {} - } - } catch {} - - // Check archive - const archiveDir = path.join(cwd, '.planning', 'archive'); - let archivedMilestones = []; - try { - archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name); - } catch {} - - const result = { - // Config - commit_docs: config.commit_docs, - - // Current milestone - milestone_version: milestone.version, - milestone_name: milestone.name, - milestone_slug: generateSlugInternal(milestone.name), - - // Phase counts - phase_count: phaseCount, - completed_phases: completedPhases, - all_phases_complete: phaseCount > 0 && phaseCount === completedPhases, - - // Archive - archived_milestones: archivedMilestones, - archive_count: archivedMilestones.length, - - // File existence - project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - archive_exists: pathExistsInternal(cwd, '.planning/archive'), - phases_dir_exists: pathExistsInternal(cwd, '.planning/phases'), - }; - - output(result, raw); -} - -function cmdInitMapCodebase(cwd, raw) { - const config = loadConfig(cwd); - - // Check for existing codebase maps - const codebaseDir = path.join(cwd, '.planning', 'codebase'); - let existingMaps = []; - try { - existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md')); - } catch {} - - const result = { - // Models - mapper_model: resolveModelInternal(cwd, 'gsd-codebase-mapper'), - - // Config - commit_docs: config.commit_docs, - search_gitignored: config.search_gitignored, - parallelization: config.parallelization, - - // Paths - codebase_dir: '.planning/codebase', - - // Existing maps - existing_maps: existingMaps, - has_maps: existingMaps.length > 0, - - // File existence - planning_exists: pathExistsInternal(cwd, '.planning'), - codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'), - }; - - output(result, raw); -} - -function cmdInitProgress(cwd, raw) { - const config = loadConfig(cwd); - const milestone = getMilestoneInfo(cwd); - - // Analyze phases - const phasesDir = path.join(cwd, '.planning', 'phases'); - const phases = []; - let currentPhase = null; - let nextPhase = null; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - - for (const dir of dirs) { - const match = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); - const phaseNumber = match ? match[1] : dir; - const phaseName = match && match[2] ? match[2] : null; - - const phasePath = path.join(phasesDir, dir); - const phaseFiles = fs.readdirSync(phasePath); - - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - - const status = summaries.length >= plans.length && plans.length > 0 ? 'complete' : - plans.length > 0 ? 'in_progress' : - hasResearch ? 'researched' : 'pending'; - - const phaseInfo = { - number: phaseNumber, - name: phaseName, - directory: path.join('.planning', 'phases', dir), - status, - plan_count: plans.length, - summary_count: summaries.length, - has_research: hasResearch, - }; - - phases.push(phaseInfo); - - // Find current (first incomplete with plans) and next (first pending) - if (!currentPhase && (status === 'in_progress' || status === 'researched')) { - currentPhase = phaseInfo; - } - if (!nextPhase && status === 'pending') { - nextPhase = phaseInfo; - } - } - } catch {} - - // Check for paused work - let pausedAt = null; - try { - const state = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8'); - const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/); - if (pauseMatch) pausedAt = pauseMatch[1].trim(); - } catch {} - - const result = { - // Models - executor_model: resolveModelInternal(cwd, 'gsd-executor'), - planner_model: resolveModelInternal(cwd, 'gsd-planner'), - - // Config - commit_docs: config.commit_docs, - - // Milestone - milestone_version: milestone.version, - milestone_name: milestone.name, - - // Phase overview - phases, - phase_count: phases.length, - completed_count: phases.filter(p => p.status === 'complete').length, - in_progress_count: phases.filter(p => p.status === 'in_progress').length, - - // Current state - current_phase: currentPhase, - next_phase: nextPhase, - paused_at: pausedAt, - has_work_in_progress: !!currentPhase, - - // File existence - project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - project_path: '.planning/PROJECT.md', - config_path: '.planning/config.json', - }; - - output(result, raw); -} +const { error } = require('./lib/core.cjs'); +const state = require('./lib/state.cjs'); +const phase = require('./lib/phase.cjs'); +const roadmap = require('./lib/roadmap.cjs'); +const verify = require('./lib/verify.cjs'); +const config = require('./lib/config.cjs'); +const template = require('./lib/template.cjs'); +const milestone = require('./lib/milestone.cjs'); +const commands = require('./lib/commands.cjs'); +const init = require('./lib/init.cjs'); +const frontmatter = require('./lib/frontmatter.cjs'); // ─── CLI Router ─────────────────────────────────────────────────────────────── @@ -4925,9 +154,9 @@ async function main() { case 'state': { const subcommand = args[1]; if (subcommand === 'update') { - cmdStateUpdate(cwd, args[2], args[3]); + state.cmdStateUpdate(cwd, args[2], args[3]); } else if (subcommand === 'get') { - cmdStateGet(cwd, args[2], raw); + state.cmdStateGet(cwd, args[2], raw); } else if (subcommand === 'patch') { const patches = {}; for (let i = 2; i < args.length; i += 2) { @@ -4937,16 +166,16 @@ async function main() { patches[key] = value; } } - cmdStatePatch(cwd, patches, raw); + state.cmdStatePatch(cwd, patches, raw); } else if (subcommand === 'advance-plan') { - cmdStateAdvancePlan(cwd, raw); + state.cmdStateAdvancePlan(cwd, raw); } else if (subcommand === 'record-metric') { const phaseIdx = args.indexOf('--phase'); const planIdx = args.indexOf('--plan'); const durationIdx = args.indexOf('--duration'); const tasksIdx = args.indexOf('--tasks'); const filesIdx = args.indexOf('--files'); - cmdStateRecordMetric(cwd, { + state.cmdStateRecordMetric(cwd, { phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, plan: planIdx !== -1 ? args[planIdx + 1] : null, duration: durationIdx !== -1 ? args[durationIdx + 1] : null, @@ -4954,42 +183,42 @@ async function main() { files: filesIdx !== -1 ? args[filesIdx + 1] : null, }, raw); } else if (subcommand === 'update-progress') { - cmdStateUpdateProgress(cwd, raw); + state.cmdStateUpdateProgress(cwd, raw); } else if (subcommand === 'add-decision') { const phaseIdx = args.indexOf('--phase'); const summaryIdx = args.indexOf('--summary'); const rationaleIdx = args.indexOf('--rationale'); - cmdStateAddDecision(cwd, { + state.cmdStateAddDecision(cwd, { phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, summary: summaryIdx !== -1 ? args[summaryIdx + 1] : null, rationale: rationaleIdx !== -1 ? args[rationaleIdx + 1] : '', }, raw); } else if (subcommand === 'add-blocker') { const textIdx = args.indexOf('--text'); - cmdStateAddBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); + state.cmdStateAddBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); } else if (subcommand === 'resolve-blocker') { const textIdx = args.indexOf('--text'); - cmdStateResolveBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); + state.cmdStateResolveBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); } else if (subcommand === 'record-session') { const stoppedIdx = args.indexOf('--stopped-at'); const resumeIdx = args.indexOf('--resume-file'); - cmdStateRecordSession(cwd, { + state.cmdStateRecordSession(cwd, { stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null, resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None', }, raw); } else { - cmdStateLoad(cwd, raw); + state.cmdStateLoad(cwd, raw); } break; } case 'resolve-model': { - cmdResolveModel(cwd, args[1], raw); + commands.cmdResolveModel(cwd, args[1], raw); break; } case 'find-phase': { - cmdFindPhase(cwd, args[1], raw); + phase.cmdFindPhase(cwd, args[1], raw); break; } @@ -4999,7 +228,7 @@ async function main() { // Parse --files flag (collect args after --files, stopping at other flags) const filesIndex = args.indexOf('--files'); const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : []; - cmdCommit(cwd, message, files, raw, amend); + commands.cmdCommit(cwd, message, files, raw, amend); break; } @@ -5007,14 +236,14 @@ async function main() { const summaryPath = args[1]; const countIndex = args.indexOf('--check-count'); const checkCount = countIndex !== -1 ? parseInt(args[countIndex + 1], 10) : 2; - cmdVerifySummary(cwd, summaryPath, checkCount, raw); + verify.cmdVerifySummary(cwd, summaryPath, checkCount, raw); break; } case 'template': { const subcommand = args[1]; if (subcommand === 'select') { - cmdTemplateSelect(cwd, args[2], raw); + template.cmdTemplateSelect(cwd, args[2], raw); } else if (subcommand === 'fill') { const templateType = args[2]; const phaseIdx = args.indexOf('--phase'); @@ -5023,7 +252,7 @@ async function main() { const typeIdx = args.indexOf('--type'); const waveIdx = args.indexOf('--wave'); const fieldsIdx = args.indexOf('--fields'); - cmdTemplateFill(cwd, templateType, { + template.cmdTemplateFill(cwd, templateType, { phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, plan: planIdx !== -1 ? args[planIdx + 1] : null, name: nameIdx !== -1 ? args[nameIdx + 1] : null, @@ -5042,17 +271,17 @@ async function main() { const file = args[2]; if (subcommand === 'get') { const fieldIdx = args.indexOf('--field'); - cmdFrontmatterGet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, raw); + frontmatter.cmdFrontmatterGet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, raw); } else if (subcommand === 'set') { const fieldIdx = args.indexOf('--field'); const valueIdx = args.indexOf('--value'); - cmdFrontmatterSet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, valueIdx !== -1 ? args[valueIdx + 1] : undefined, raw); + frontmatter.cmdFrontmatterSet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, valueIdx !== -1 ? args[valueIdx + 1] : undefined, raw); } else if (subcommand === 'merge') { const dataIdx = args.indexOf('--data'); - cmdFrontmatterMerge(cwd, file, dataIdx !== -1 ? args[dataIdx + 1] : null, raw); + frontmatter.cmdFrontmatterMerge(cwd, file, dataIdx !== -1 ? args[dataIdx + 1] : null, raw); } else if (subcommand === 'validate') { const schemaIdx = args.indexOf('--schema'); - cmdFrontmatterValidate(cwd, file, schemaIdx !== -1 ? args[schemaIdx + 1] : null, raw); + frontmatter.cmdFrontmatterValidate(cwd, file, schemaIdx !== -1 ? args[schemaIdx + 1] : null, raw); } else { error('Unknown frontmatter subcommand. Available: get, set, merge, validate'); } @@ -5062,17 +291,17 @@ async function main() { case 'verify': { const subcommand = args[1]; if (subcommand === 'plan-structure') { - cmdVerifyPlanStructure(cwd, args[2], raw); + verify.cmdVerifyPlanStructure(cwd, args[2], raw); } else if (subcommand === 'phase-completeness') { - cmdVerifyPhaseCompleteness(cwd, args[2], raw); + verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw); } else if (subcommand === 'references') { - cmdVerifyReferences(cwd, args[2], raw); + verify.cmdVerifyReferences(cwd, args[2], raw); } else if (subcommand === 'commits') { - cmdVerifyCommits(cwd, args.slice(2), raw); + verify.cmdVerifyCommits(cwd, args.slice(2), raw); } else if (subcommand === 'artifacts') { - cmdVerifyArtifacts(cwd, args[2], raw); + verify.cmdVerifyArtifacts(cwd, args[2], raw); } else if (subcommand === 'key-links') { - cmdVerifyKeyLinks(cwd, args[2], raw); + verify.cmdVerifyKeyLinks(cwd, args[2], raw); } else { error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links'); } @@ -5080,42 +309,42 @@ async function main() { } case 'generate-slug': { - cmdGenerateSlug(args[1], raw); + commands.cmdGenerateSlug(args[1], raw); break; } case 'current-timestamp': { - cmdCurrentTimestamp(args[1] || 'full', raw); + commands.cmdCurrentTimestamp(args[1] || 'full', raw); break; } case 'list-todos': { - cmdListTodos(cwd, args[1], raw); + commands.cmdListTodos(cwd, args[1], raw); break; } case 'verify-path-exists': { - cmdVerifyPathExists(cwd, args[1], raw); + commands.cmdVerifyPathExists(cwd, args[1], raw); break; } case 'config-ensure-section': { - cmdConfigEnsureSection(cwd, raw); + config.cmdConfigEnsureSection(cwd, raw); break; } case 'config-set': { - cmdConfigSet(cwd, args[1], args[2], raw); + config.cmdConfigSet(cwd, args[1], args[2], raw); break; } case 'config-get': { - cmdConfigGet(cwd, args[1], raw); + config.cmdConfigGet(cwd, args[1], raw); break; } case 'history-digest': { - cmdHistoryDigest(cwd, raw); + commands.cmdHistoryDigest(cwd, raw); break; } @@ -5129,7 +358,7 @@ async function main() { phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null, includeArchived: args.includes('--include-archived'), }; - cmdPhasesList(cwd, options, raw); + phase.cmdPhasesList(cwd, options, raw); } else { error('Unknown phases subcommand. Available: list'); } @@ -5139,11 +368,11 @@ async function main() { case 'roadmap': { const subcommand = args[1]; if (subcommand === 'get-phase') { - cmdRoadmapGetPhase(cwd, args[2], raw); + roadmap.cmdRoadmapGetPhase(cwd, args[2], raw); } else if (subcommand === 'analyze') { - cmdRoadmapAnalyze(cwd, raw); + roadmap.cmdRoadmapAnalyze(cwd, raw); } else if (subcommand === 'update-plan-progress') { - cmdRoadmapUpdatePlanProgress(cwd, args[2], raw); + roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw); } else { error('Unknown roadmap subcommand. Available: get-phase, analyze, update-plan-progress'); } @@ -5153,7 +382,7 @@ async function main() { case 'requirements': { const subcommand = args[1]; if (subcommand === 'mark-complete') { - cmdRequirementsMarkComplete(cwd, args.slice(2), raw); + milestone.cmdRequirementsMarkComplete(cwd, args.slice(2), raw); } else { error('Unknown requirements subcommand. Available: mark-complete'); } @@ -5163,16 +392,16 @@ async function main() { case 'phase': { const subcommand = args[1]; if (subcommand === 'next-decimal') { - cmdPhaseNextDecimal(cwd, args[2], raw); + phase.cmdPhaseNextDecimal(cwd, args[2], raw); } else if (subcommand === 'add') { - cmdPhaseAdd(cwd, args.slice(2).join(' '), raw); + phase.cmdPhaseAdd(cwd, args.slice(2).join(' '), raw); } else if (subcommand === 'insert') { - cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw); + phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw); } else if (subcommand === 'remove') { const forceFlag = args.includes('--force'); - cmdPhaseRemove(cwd, args[2], { force: forceFlag }, raw); + phase.cmdPhaseRemove(cwd, args[2], { force: forceFlag }, raw); } else if (subcommand === 'complete') { - cmdPhaseComplete(cwd, args[2], raw); + phase.cmdPhaseComplete(cwd, args[2], raw); } else { error('Unknown phase subcommand. Available: next-decimal, add, insert, remove, complete'); } @@ -5194,7 +423,7 @@ async function main() { } milestoneName = nameArgs.join(' ') || null; } - cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases }, raw); + milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases }, raw); } else { error('Unknown milestone subcommand. Available: complete'); } @@ -5204,10 +433,10 @@ async function main() { case 'validate': { const subcommand = args[1]; if (subcommand === 'consistency') { - cmdValidateConsistency(cwd, raw); + verify.cmdValidateConsistency(cwd, raw); } else if (subcommand === 'health') { const repairFlag = args.includes('--repair'); - cmdValidateHealth(cwd, { repair: repairFlag }, raw); + verify.cmdValidateHealth(cwd, { repair: repairFlag }, raw); } else { error('Unknown validate subcommand. Available: consistency, health'); } @@ -5216,14 +445,14 @@ async function main() { case 'progress': { const subcommand = args[1] || 'json'; - cmdProgressRender(cwd, subcommand, raw); + commands.cmdProgressRender(cwd, subcommand, raw); break; } case 'todo': { const subcommand = args[1]; if (subcommand === 'complete') { - cmdTodoComplete(cwd, args[2], raw); + commands.cmdTodoComplete(cwd, args[2], raw); } else { error('Unknown todo subcommand. Available: complete'); } @@ -5238,7 +467,7 @@ async function main() { phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null, name: nameIndex !== -1 ? args.slice(nameIndex + 1).join(' ') : null, }; - cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw); + commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw); break; } @@ -5246,40 +475,40 @@ async function main() { const workflow = args[1]; switch (workflow) { case 'execute-phase': - cmdInitExecutePhase(cwd, args[2], raw); + init.cmdInitExecutePhase(cwd, args[2], raw); break; case 'plan-phase': - cmdInitPlanPhase(cwd, args[2], raw); + init.cmdInitPlanPhase(cwd, args[2], raw); break; case 'new-project': - cmdInitNewProject(cwd, raw); + init.cmdInitNewProject(cwd, raw); break; case 'new-milestone': - cmdInitNewMilestone(cwd, raw); + init.cmdInitNewMilestone(cwd, raw); break; case 'quick': - cmdInitQuick(cwd, args.slice(2).join(' '), raw); + init.cmdInitQuick(cwd, args.slice(2).join(' '), raw); break; case 'resume': - cmdInitResume(cwd, raw); + init.cmdInitResume(cwd, raw); break; case 'verify-work': - cmdInitVerifyWork(cwd, args[2], raw); + init.cmdInitVerifyWork(cwd, args[2], raw); break; case 'phase-op': - cmdInitPhaseOp(cwd, args[2], raw); + init.cmdInitPhaseOp(cwd, args[2], raw); break; case 'todos': - cmdInitTodos(cwd, args[2], raw); + init.cmdInitTodos(cwd, args[2], raw); break; case 'milestone-op': - cmdInitMilestoneOp(cwd, raw); + init.cmdInitMilestoneOp(cwd, raw); break; case 'map-codebase': - cmdInitMapCodebase(cwd, raw); + init.cmdInitMapCodebase(cwd, raw); break; case 'progress': - cmdInitProgress(cwd, raw); + init.cmdInitProgress(cwd, raw); break; default: error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress`); @@ -5288,12 +517,12 @@ async function main() { } case 'phase-plan-index': { - cmdPhasePlanIndex(cwd, args[1], raw); + phase.cmdPhasePlanIndex(cwd, args[1], raw); break; } case 'state-snapshot': { - cmdStateSnapshot(cwd, raw); + state.cmdStateSnapshot(cwd, raw); break; } @@ -5301,7 +530,7 @@ async function main() { const summaryPath = args[1]; const fieldsIndex = args.indexOf('--fields'); const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null; - cmdSummaryExtract(cwd, summaryPath, fields, raw); + commands.cmdSummaryExtract(cwd, summaryPath, fields, raw); break; } @@ -5309,7 +538,7 @@ async function main() { const query = args[1]; const limitIdx = args.indexOf('--limit'); const freshnessIdx = args.indexOf('--freshness'); - await cmdWebsearch(query, { + await commands.cmdWebsearch(query, { limit: limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 10, freshness: freshnessIdx !== -1 ? args[freshnessIdx + 1] : null, }, raw); diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs new file mode 100644 index 000000000..6978f6365 --- /dev/null +++ b/get-shit-done/bin/lib/commands.cjs @@ -0,0 +1,556 @@ +/** + * Commands — Standalone utility commands + */ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, output, error, findPhaseInternal } = require('./core.cjs'); +const { extractFrontmatter } = require('./frontmatter.cjs'); + +function cmdGenerateSlug(text, raw) { + if (!text) { + error('text required for slug generation'); + } + + const slug = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + const result = { slug }; + output(result, raw, slug); +} + +function cmdCurrentTimestamp(format, raw) { + const now = new Date(); + let result; + + switch (format) { + case 'date': + result = now.toISOString().split('T')[0]; + break; + case 'filename': + result = now.toISOString().replace(/:/g, '-').replace(/\..+/, ''); + break; + case 'full': + default: + result = now.toISOString(); + break; + } + + output({ timestamp: result }, raw, result); +} + +function cmdListTodos(cwd, area, raw) { + const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); + + let count = 0; + const todos = []; + + try { + const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md')); + + for (const file of files) { + try { + const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8'); + const createdMatch = content.match(/^created:\s*(.+)$/m); + const titleMatch = content.match(/^title:\s*(.+)$/m); + const areaMatch = content.match(/^area:\s*(.+)$/m); + + const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; + + // Apply area filter if specified + if (area && todoArea !== area) continue; + + count++; + todos.push({ + file, + created: createdMatch ? createdMatch[1].trim() : 'unknown', + title: titleMatch ? titleMatch[1].trim() : 'Untitled', + area: todoArea, + path: path.join('.planning', 'todos', 'pending', file), + }); + } catch {} + } + } catch {} + + const result = { count, todos }; + output(result, raw, count.toString()); +} + +function cmdVerifyPathExists(cwd, targetPath, raw) { + if (!targetPath) { + error('path required for verification'); + } + + const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); + + try { + const stats = fs.statSync(fullPath); + const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other'; + const result = { exists: true, type }; + output(result, raw, 'true'); + } catch { + const result = { exists: false, type: null }; + output(result, raw, 'false'); + } +} + +function cmdHistoryDigest(cwd, raw) { + const phasesDir = path.join(cwd, '.planning', 'phases'); + const digest = { phases: {}, decisions: [], tech_stack: new Set() }; + + // Collect all phase directories: archived + current + const allPhaseDirs = []; + + // Add archived phases first (oldest milestones first) + const archived = getArchivedPhaseDirs(cwd); + for (const a of archived) { + allPhaseDirs.push({ name: a.name, fullPath: a.fullPath, milestone: a.milestone }); + } + + // Add current phases + if (fs.existsSync(phasesDir)) { + try { + const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort(); + for (const dir of currentDirs) { + allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null }); + } + } catch {} + } + + if (allPhaseDirs.length === 0) { + digest.tech_stack = []; + output(digest, raw); + return; + } + + try { + for (const { name: dir, fullPath: dirPath } of allPhaseDirs) { + const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + for (const summary of summaries) { + try { + const content = fs.readFileSync(path.join(dirPath, summary), 'utf-8'); + const fm = extractFrontmatter(content); + + const phaseNum = fm.phase || dir.split('-')[0]; + + if (!digest.phases[phaseNum]) { + digest.phases[phaseNum] = { + name: fm.name || dir.split('-').slice(1).join(' ') || 'Unknown', + provides: new Set(), + affects: new Set(), + patterns: new Set(), + }; + } + + // Merge provides + if (fm['dependency-graph'] && fm['dependency-graph'].provides) { + fm['dependency-graph'].provides.forEach(p => digest.phases[phaseNum].provides.add(p)); + } else if (fm.provides) { + fm.provides.forEach(p => digest.phases[phaseNum].provides.add(p)); + } + + // Merge affects + if (fm['dependency-graph'] && fm['dependency-graph'].affects) { + fm['dependency-graph'].affects.forEach(a => digest.phases[phaseNum].affects.add(a)); + } + + // Merge patterns + if (fm['patterns-established']) { + fm['patterns-established'].forEach(p => digest.phases[phaseNum].patterns.add(p)); + } + + // Merge decisions + if (fm['key-decisions']) { + fm['key-decisions'].forEach(d => { + digest.decisions.push({ phase: phaseNum, decision: d }); + }); + } + + // Merge tech stack + if (fm['tech-stack'] && fm['tech-stack'].added) { + fm['tech-stack'].added.forEach(t => digest.tech_stack.add(typeof t === 'string' ? t : t.name)); + } + + } catch (e) { + // Skip malformed summaries + } + } + } + + // Convert Sets to Arrays for JSON output + Object.keys(digest.phases).forEach(p => { + digest.phases[p].provides = [...digest.phases[p].provides]; + digest.phases[p].affects = [...digest.phases[p].affects]; + digest.phases[p].patterns = [...digest.phases[p].patterns]; + }); + digest.tech_stack = [...digest.tech_stack]; + + output(digest, raw); + } catch (e) { + error('Failed to generate history digest: ' + e.message); + } +} + +function cmdResolveModel(cwd, agentType, raw) { + if (!agentType) { + error('agent-type required'); + } + + const config = loadConfig(cwd); + const profile = config.model_profile || 'balanced'; + + const agentModels = MODEL_PROFILES[agentType]; + if (!agentModels) { + const result = { model: 'sonnet', profile, unknown_agent: true }; + output(result, raw, 'sonnet'); + return; + } + + const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet'; + const model = resolved === 'opus' ? 'inherit' : resolved; + const result = { model, profile }; + output(result, raw, model); +} + +function cmdCommit(cwd, message, files, raw, amend) { + if (!message && !amend) { + error('commit message required'); + } + + const config = loadConfig(cwd); + + // Check commit_docs config + if (!config.commit_docs) { + const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' }; + output(result, raw, 'skipped'); + return; + } + + // Check if .planning is gitignored + if (isGitIgnored(cwd, '.planning')) { + const result = { committed: false, hash: null, reason: 'skipped_gitignored' }; + output(result, raw, 'skipped'); + return; + } + + // Stage files + const filesToStage = files && files.length > 0 ? files : ['.planning/']; + for (const file of filesToStage) { + execGit(cwd, ['add', file]); + } + + // Commit + const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message]; + const commitResult = execGit(cwd, commitArgs); + if (commitResult.exitCode !== 0) { + if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) { + const result = { committed: false, hash: null, reason: 'nothing_to_commit' }; + output(result, raw, 'nothing'); + return; + } + const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr }; + output(result, raw, 'nothing'); + return; + } + + // Get short hash + const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']); + const hash = hashResult.exitCode === 0 ? hashResult.stdout : null; + const result = { committed: true, hash, reason: 'committed' }; + output(result, raw, hash || 'committed'); +} + +function cmdSummaryExtract(cwd, summaryPath, fields, raw) { + if (!summaryPath) { + error('summary-path required for summary-extract'); + } + + const fullPath = path.join(cwd, summaryPath); + + if (!fs.existsSync(fullPath)) { + output({ error: 'File not found', path: summaryPath }, raw); + return; + } + + const content = fs.readFileSync(fullPath, 'utf-8'); + const fm = extractFrontmatter(content); + + // Parse key-decisions into structured format + const parseDecisions = (decisionsList) => { + if (!decisionsList || !Array.isArray(decisionsList)) return []; + return decisionsList.map(d => { + const colonIdx = d.indexOf(':'); + if (colonIdx > 0) { + return { + summary: d.substring(0, colonIdx).trim(), + rationale: d.substring(colonIdx + 1).trim(), + }; + } + return { summary: d, rationale: null }; + }); + }; + + // Build full result + const fullResult = { + path: summaryPath, + one_liner: fm['one-liner'] || null, + key_files: fm['key-files'] || [], + tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [], + patterns: fm['patterns-established'] || [], + decisions: parseDecisions(fm['key-decisions']), + }; + + // If fields specified, filter to only those fields + if (fields && fields.length > 0) { + const filtered = { path: summaryPath }; + for (const field of fields) { + if (fullResult[field] !== undefined) { + filtered[field] = fullResult[field]; + } + } + output(filtered, raw); + return; + } + + output(fullResult, raw); +} + +async function cmdWebsearch(query, options, raw) { + const apiKey = process.env.BRAVE_API_KEY; + + if (!apiKey) { + // No key = silent skip, agent falls back to built-in WebSearch + output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, ''); + return; + } + + if (!query) { + output({ available: false, error: 'Query required' }, raw, ''); + return; + } + + const params = new URLSearchParams({ + q: query, + count: String(options.limit || 10), + country: 'us', + search_lang: 'en', + text_decorations: 'false' + }); + + if (options.freshness) { + params.set('freshness', options.freshness); + } + + try { + const response = await fetch( + `https://api.search.brave.com/res/v1/web/search?${params}`, + { + headers: { + 'Accept': 'application/json', + 'X-Subscription-Token': apiKey + } + } + ); + + if (!response.ok) { + output({ available: false, error: `API error: ${response.status}` }, raw, ''); + return; + } + + const data = await response.json(); + + const results = (data.web?.results || []).map(r => ({ + title: r.title, + url: r.url, + description: r.description, + age: r.age || null + })); + + output({ + available: true, + query, + count: results.length, + results + }, raw, results.map(r => `${r.title}\n${r.url}\n${r.description}`).join('\n\n')); + } catch (err) { + output({ available: false, error: err.message }, raw, ''); + } +} + +function cmdProgressRender(cwd, format, raw) { + const phasesDir = path.join(cwd, '.planning', 'phases'); + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const milestone = getMilestoneInfo(cwd); + + const phases = []; + let totalPlans = 0; + let totalSummaries = 0; + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => { + const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); + const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); + return aNum - bNum; + }); + + for (const dir of dirs) { + const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); + const phaseNum = dm ? dm[1] : dir; + const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; + const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; + + totalPlans += plans; + totalSummaries += summaries; + + let status; + if (plans === 0) status = 'Pending'; + else if (summaries >= plans) status = 'Complete'; + else if (summaries > 0) status = 'In Progress'; + else status = 'Planned'; + + phases.push({ number: phaseNum, name: phaseName, plans, summaries, status }); + } + } catch {} + + const percent = totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0; + + if (format === 'table') { + // Render markdown table + const barWidth = 10; + const filled = Math.round((percent / 100) * barWidth); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); + let out = `# ${milestone.version} ${milestone.name}\n\n`; + out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`; + out += `| Phase | Name | Plans | Status |\n`; + out += `|-------|------|-------|--------|\n`; + for (const p of phases) { + out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`; + } + output({ rendered: out }, raw, out); + } else if (format === 'bar') { + const barWidth = 20; + const filled = Math.round((percent / 100) * barWidth); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); + const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`; + output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text); + } else { + // JSON format + output({ + milestone_version: milestone.version, + milestone_name: milestone.name, + phases, + total_plans: totalPlans, + total_summaries: totalSummaries, + percent, + }, raw); + } +} + +function cmdTodoComplete(cwd, filename, raw) { + if (!filename) { + error('filename required for todo complete'); + } + + const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); + const completedDir = path.join(cwd, '.planning', 'todos', 'completed'); + const sourcePath = path.join(pendingDir, filename); + + if (!fs.existsSync(sourcePath)) { + error(`Todo not found: ${filename}`); + } + + // Ensure completed directory exists + fs.mkdirSync(completedDir, { recursive: true }); + + // Read, add completion timestamp, move + let content = fs.readFileSync(sourcePath, 'utf-8'); + const today = new Date().toISOString().split('T')[0]; + content = `completed: ${today}\n` + content; + + fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8'); + fs.unlinkSync(sourcePath); + + output({ completed: true, file: filename, date: today }, raw, 'completed'); +} + +function cmdScaffold(cwd, type, options, raw) { + const { phase, name } = options; + const padded = phase ? normalizePhaseName(phase) : '00'; + const today = new Date().toISOString().split('T')[0]; + + // Find phase directory + const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null; + const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null; + + if (phase && !phaseDir && type !== 'phase-dir') { + error(`Phase ${phase} directory not found`); + } + + let filePath, content; + + switch (type) { + case 'context': { + filePath = path.join(phaseDir, `${padded}-CONTEXT.md`); + content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Context\n\n## Decisions\n\n_Decisions will be captured during /gsd:discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`; + break; + } + case 'uat': { + filePath = path.join(phaseDir, `${padded}-UAT.md`); + content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`; + break; + } + case 'verification': { + filePath = path.join(phaseDir, `${padded}-VERIFICATION.md`); + content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`; + break; + } + case 'phase-dir': { + if (!phase || !name) { + error('phase and name required for phase-dir scaffold'); + } + const slug = generateSlugInternal(name); + const dirName = `${padded}-${slug}`; + const phasesParent = path.join(cwd, '.planning', 'phases'); + fs.mkdirSync(phasesParent, { recursive: true }); + const dirPath = path.join(phasesParent, dirName); + fs.mkdirSync(dirPath, { recursive: true }); + output({ created: true, directory: `.planning/phases/${dirName}`, path: dirPath }, raw, dirPath); + return; + } + default: + error(`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`); + } + + if (fs.existsSync(filePath)) { + output({ created: false, reason: 'already_exists', path: filePath }, raw, 'exists'); + return; + } + + fs.writeFileSync(filePath, content, 'utf-8'); + const relPath = path.relative(cwd, filePath); + output({ created: true, path: relPath }, raw, relPath); +} + +module.exports = { + cmdGenerateSlug, + cmdCurrentTimestamp, + cmdListTodos, + cmdVerifyPathExists, + cmdHistoryDigest, + cmdResolveModel, + cmdCommit, + cmdSummaryExtract, + cmdWebsearch, + cmdProgressRender, + cmdTodoComplete, + cmdScaffold, +}; diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs new file mode 100644 index 000000000..2eb234e69 --- /dev/null +++ b/get-shit-done/bin/lib/config.cjs @@ -0,0 +1,161 @@ +/** + * Config — Planning config CRUD operations + */ + +const fs = require('fs'); +const path = require('path'); +const { output, error } = require('./core.cjs'); + +function cmdConfigEnsureSection(cwd, raw) { + const configPath = path.join(cwd, '.planning', 'config.json'); + const planningDir = path.join(cwd, '.planning'); + + // Ensure .planning directory exists + try { + if (!fs.existsSync(planningDir)) { + fs.mkdirSync(planningDir, { recursive: true }); + } + } catch (err) { + error('Failed to create .planning directory: ' + err.message); + } + + // Check if config already exists + if (fs.existsSync(configPath)) { + const result = { created: false, reason: 'already_exists' }; + output(result, raw, 'exists'); + return; + } + + // Detect Brave Search API key availability + const homedir = require('os').homedir(); + const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); + const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); + + // Load user-level defaults from ~/.gsd/defaults.json if available + const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json'); + let userDefaults = {}; + try { + if (fs.existsSync(globalDefaultsPath)) { + userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')); + } + } catch (err) { + // Ignore malformed global defaults, fall back to hardcoded + } + + // Create default config (user-level defaults override hardcoded defaults) + const hardcoded = { + model_profile: 'balanced', + commit_docs: true, + search_gitignored: false, + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + workflow: { + research: true, + plan_check: true, + verifier: true, + }, + parallelization: true, + brave_search: hasBraveSearch, + }; + const defaults = { + ...hardcoded, + ...userDefaults, + workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}) }, + }; + + try { + fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); + const result = { created: true, path: '.planning/config.json' }; + output(result, raw, 'created'); + } catch (err) { + error('Failed to create config.json: ' + err.message); + } +} + +function cmdConfigSet(cwd, keyPath, value, raw) { + const configPath = path.join(cwd, '.planning', 'config.json'); + + if (!keyPath) { + error('Usage: config-set '); + } + + // Parse value (handle booleans and numbers) + let parsedValue = value; + if (value === 'true') parsedValue = true; + else if (value === 'false') parsedValue = false; + else if (!isNaN(value) && value !== '') parsedValue = Number(value); + + // Load existing config or start with empty object + let config = {}; + try { + if (fs.existsSync(configPath)) { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch (err) { + error('Failed to read config.json: ' + err.message); + } + + // Set nested value using dot notation (e.g., "workflow.research") + const keys = keyPath.split('.'); + let current = config; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (current[key] === undefined || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key]; + } + current[keys[keys.length - 1]] = parsedValue; + + // Write back + try { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + const result = { updated: true, key: keyPath, value: parsedValue }; + output(result, raw, `${keyPath}=${parsedValue}`); + } catch (err) { + error('Failed to write config.json: ' + err.message); + } +} + +function cmdConfigGet(cwd, keyPath, raw) { + const configPath = path.join(cwd, '.planning', 'config.json'); + + if (!keyPath) { + error('Usage: config-get '); + } + + let config = {}; + try { + if (fs.existsSync(configPath)) { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } else { + error('No config.json found at ' + configPath); + } + } catch (err) { + if (err.message.startsWith('No config.json')) throw err; + error('Failed to read config.json: ' + err.message); + } + + // Traverse dot-notation path (e.g., "workflow.auto_advance") + const keys = keyPath.split('.'); + let current = config; + for (const key of keys) { + if (current === undefined || current === null || typeof current !== 'object') { + error(`Key not found: ${keyPath}`); + } + current = current[key]; + } + + if (current === undefined) { + error(`Key not found: ${keyPath}`); + } + + output(current, raw, String(current)); +} + +module.exports = { + cmdConfigEnsureSection, + cmdConfigSet, + cmdConfigGet, +}; diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs new file mode 100644 index 000000000..36ef25da3 --- /dev/null +++ b/get-shit-done/bin/lib/core.cjs @@ -0,0 +1,377 @@ +/** + * Core — Shared utilities, constants, and internal helpers + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// ─── Model Profile Table ───────────────────────────────────────────────────── + +const MODEL_PROFILES = { + 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' }, + 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, + 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, + 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, + 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' }, + 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, +}; + +// ─── Output helpers ─────────────────────────────────────────────────────────── + +function output(result, raw, rawValue) { + if (raw && rawValue !== undefined) { + process.stdout.write(String(rawValue)); + } else { + const json = JSON.stringify(result, null, 2); + // Large payloads exceed Claude Code's Bash tool buffer (~50KB). + // Write to tmpfile and output the path prefixed with @file: so callers can detect it. + if (json.length > 50000) { + const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`); + fs.writeFileSync(tmpPath, json, 'utf-8'); + process.stdout.write('@file:' + tmpPath); + } else { + process.stdout.write(json); + } + } + process.exit(0); +} + +function error(message) { + process.stderr.write('Error: ' + message + '\n'); + process.exit(1); +} + +// ─── File & Config utilities ────────────────────────────────────────────────── + +function safeReadFile(filePath) { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } +} + +function loadConfig(cwd) { + const configPath = path.join(cwd, '.planning', 'config.json'); + const defaults = { + model_profile: 'balanced', + commit_docs: true, + search_gitignored: false, + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + research: true, + plan_checker: true, + verifier: true, + parallelization: true, + brave_search: false, + }; + + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw); + + const get = (key, nested) => { + if (parsed[key] !== undefined) return parsed[key]; + if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) { + return parsed[nested.section][nested.field]; + } + return undefined; + }; + + const parallelization = (() => { + const val = get('parallelization'); + if (typeof val === 'boolean') return val; + if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled; + return defaults.parallelization; + })(); + + return { + model_profile: get('model_profile') ?? defaults.model_profile, + commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs, + search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored, + branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy, + phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template, + milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template, + research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research, + plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker, + verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier, + parallelization, + brave_search: get('brave_search') ?? defaults.brave_search, + }; + } catch { + return defaults; + } +} + +// ─── Git utilities ──────────────────────────────────────────────────────────── + +function isGitIgnored(cwd, targetPath) { + try { + execSync('git check-ignore -q -- ' + targetPath.replace(/[^a-zA-Z0-9._\-/]/g, ''), { + cwd, + stdio: 'pipe', + }); + return true; + } catch { + return false; + } +} + +function execGit(cwd, args) { + try { + const escaped = args.map(a => { + if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a; + return "'" + a.replace(/'/g, "'\\''") + "'"; + }); + const stdout = execSync('git ' + escaped.join(' '), { + cwd, + stdio: 'pipe', + encoding: 'utf-8', + }); + return { exitCode: 0, stdout: stdout.trim(), stderr: '' }; + } catch (err) { + return { + exitCode: err.status ?? 1, + stdout: (err.stdout ?? '').toString().trim(), + stderr: (err.stderr ?? '').toString().trim(), + }; + } +} + +// ─── Phase utilities ────────────────────────────────────────────────────────── + +function normalizePhaseName(phase) { + const match = phase.match(/^(\d+(?:\.\d+)?)/); + if (!match) return phase; + const num = match[1]; + const parts = num.split('.'); + const padded = parts[0].padStart(2, '0'); + return parts.length > 1 ? `${padded}.${parts[1]}` : padded; +} + +function searchPhaseInDir(baseDir, relBase, normalized) { + try { + const entries = fs.readdirSync(baseDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + const match = dirs.find(d => d.startsWith(normalized)); + if (!match) return null; + + const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/); + const phaseNumber = dirMatch ? dirMatch[1] : normalized; + const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; + const phaseDir = path.join(baseDir, match); + const phaseFiles = fs.readdirSync(phaseDir); + + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); + const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); + + const completedPlanIds = new Set( + summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) + ); + const incompletePlans = plans.filter(p => { + const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); + return !completedPlanIds.has(planId); + }); + + return { + found: true, + directory: path.join(relBase, match), + phase_number: phaseNumber, + phase_name: phaseName, + phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, + plans, + summaries, + incomplete_plans: incompletePlans, + has_research: hasResearch, + has_context: hasContext, + has_verification: hasVerification, + }; + } catch { + return null; + } +} + +function findPhaseInternal(cwd, phase) { + if (!phase) return null; + + const phasesDir = path.join(cwd, '.planning', 'phases'); + const normalized = normalizePhaseName(phase); + + // Search current phases first + const current = searchPhaseInDir(phasesDir, path.join('.planning', 'phases'), normalized); + if (current) return current; + + // Search archived milestone phases (newest first) + const milestonesDir = path.join(cwd, '.planning', 'milestones'); + if (!fs.existsSync(milestonesDir)) return null; + + try { + const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); + const archiveDirs = milestoneEntries + .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) + .map(e => e.name) + .sort() + .reverse(); + + for (const archiveName of archiveDirs) { + const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; + const archivePath = path.join(milestonesDir, archiveName); + const relBase = path.join('.planning', 'milestones', archiveName); + const result = searchPhaseInDir(archivePath, relBase, normalized); + if (result) { + result.archived = version; + return result; + } + } + } catch {} + + return null; +} + +function getArchivedPhaseDirs(cwd) { + const milestonesDir = path.join(cwd, '.planning', 'milestones'); + const results = []; + + if (!fs.existsSync(milestonesDir)) return results; + + try { + const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); + // Find v*-phases directories, sort newest first + const phaseDirs = milestoneEntries + .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) + .map(e => e.name) + .sort() + .reverse(); + + for (const archiveName of phaseDirs) { + const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; + const archivePath = path.join(milestonesDir, archiveName); + const entries = fs.readdirSync(archivePath, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + + for (const dir of dirs) { + results.push({ + name: dir, + milestone: version, + basePath: path.join('.planning', 'milestones', archiveName), + fullPath: path.join(archivePath, dir), + }); + } + } + } catch {} + + return results; +} + +// ─── Roadmap & model utilities ──────────────────────────────────────────────── + +function getRoadmapPhaseInternal(cwd, phaseNum) { + if (!phaseNum) return null; + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + if (!fs.existsSync(roadmapPath)) return null; + + try { + const content = fs.readFileSync(roadmapPath, 'utf-8'); + const escapedPhase = phaseNum.toString().replace(/\./g, '\\.'); + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i'); + const headerMatch = content.match(phasePattern); + if (!headerMatch) return null; + + const phaseName = headerMatch[1].trim(); + const headerIndex = headerMatch.index; + const restOfContent = content.slice(headerIndex); + const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); + const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length; + const section = content.slice(headerIndex, sectionEnd).trim(); + + const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); + const goal = goalMatch ? goalMatch[1].trim() : null; + + return { + found: true, + phase_number: phaseNum.toString(), + phase_name: phaseName, + goal, + section, + }; + } catch { + return null; + } +} + +function resolveModelInternal(cwd, agentType) { + const config = loadConfig(cwd); + + // Check per-agent override first + const override = config.model_overrides?.[agentType]; + if (override) { + return override === 'opus' ? 'inherit' : override; + } + + // Fall back to profile lookup + const profile = config.model_profile || 'balanced'; + const agentModels = MODEL_PROFILES[agentType]; + if (!agentModels) return 'sonnet'; + const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet'; + return resolved === 'opus' ? 'inherit' : resolved; +} + +// ─── Misc utilities ─────────────────────────────────────────────────────────── + +function pathExistsInternal(cwd, targetPath) { + const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); + try { + fs.statSync(fullPath); + return true; + } catch { + return false; + } +} + +function generateSlugInternal(text) { + if (!text) return null; + return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} + +function getMilestoneInfo(cwd) { + try { + const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'); + const versionMatch = roadmap.match(/v(\d+\.\d+)/); + const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/); + return { + version: versionMatch ? versionMatch[0] : 'v1.0', + name: nameMatch ? nameMatch[1].trim() : 'milestone', + }; + } catch { + return { version: 'v1.0', name: 'milestone' }; + } +} + +module.exports = { + MODEL_PROFILES, + output, + error, + safeReadFile, + loadConfig, + isGitIgnored, + execGit, + normalizePhaseName, + searchPhaseInDir, + findPhaseInternal, + getArchivedPhaseDirs, + getRoadmapPhaseInternal, + resolveModelInternal, + pathExistsInternal, + generateSlugInternal, + getMilestoneInfo, +}; diff --git a/get-shit-done/bin/lib/frontmatter.cjs b/get-shit-done/bin/lib/frontmatter.cjs new file mode 100644 index 000000000..93dccb5c3 --- /dev/null +++ b/get-shit-done/bin/lib/frontmatter.cjs @@ -0,0 +1,299 @@ +/** + * Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands + */ + +const fs = require('fs'); +const path = require('path'); +const { safeReadFile, output, error } = require('./core.cjs'); + +// ─── Parsing engine ─────────────────────────────────────────────────────────── + +function extractFrontmatter(content) { + const frontmatter = {}; + const match = content.match(/^---\n([\s\S]+?)\n---/); + if (!match) return frontmatter; + + const yaml = match[1]; + const lines = yaml.split('\n'); + + // Stack to track nested objects: [{obj, key, indent}] + // obj = object to write to, key = current key collecting array items, indent = indentation level + let stack = [{ obj: frontmatter, key: null, indent: -1 }]; + + for (const line of lines) { + // Skip empty lines + if (line.trim() === '') continue; + + // Calculate indentation (number of leading spaces) + const indentMatch = line.match(/^(\s*)/); + const indent = indentMatch ? indentMatch[1].length : 0; + + // Pop stack back to appropriate level + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { + stack.pop(); + } + + const current = stack[stack.length - 1]; + + // Check for key: value pattern + const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/); + if (keyMatch) { + const key = keyMatch[2]; + const value = keyMatch[3].trim(); + + if (value === '' || value === '[') { + // Key with no value or opening bracket — could be nested object or array + // We'll determine based on next lines, for now create placeholder + current.obj[key] = value === '[' ? [] : {}; + current.key = null; + // Push new context for potential nested content + stack.push({ obj: current.obj[key], key: null, indent }); + } else if (value.startsWith('[') && value.endsWith(']')) { + // Inline array: key: [a, b, c] + current.obj[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean); + current.key = null; + } else { + // Simple key: value + current.obj[key] = value.replace(/^["']|["']$/g, ''); + current.key = null; + } + } else if (line.trim().startsWith('- ')) { + // Array item + const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, ''); + + // If current context is an empty object, convert to array + if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) { + // Find the key in parent that points to this object and convert it + const parent = stack.length > 1 ? stack[stack.length - 2] : null; + if (parent) { + for (const k of Object.keys(parent.obj)) { + if (parent.obj[k] === current.obj) { + parent.obj[k] = [itemValue]; + current.obj = parent.obj[k]; + break; + } + } + } + } else if (Array.isArray(current.obj)) { + current.obj.push(itemValue); + } + } + } + + return frontmatter; +} + +function reconstructFrontmatter(obj) { + const lines = []; + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) continue; + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${key}: []`); + } else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) { + lines.push(`${key}: [${value.join(', ')}]`); + } else { + lines.push(`${key}:`); + for (const item of value) { + lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`); + } + } + } else if (typeof value === 'object') { + lines.push(`${key}:`); + for (const [subkey, subval] of Object.entries(value)) { + if (subval === null || subval === undefined) continue; + if (Array.isArray(subval)) { + if (subval.length === 0) { + lines.push(` ${subkey}: []`); + } else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) { + lines.push(` ${subkey}: [${subval.join(', ')}]`); + } else { + lines.push(` ${subkey}:`); + for (const item of subval) { + lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`); + } + } + } else if (typeof subval === 'object') { + lines.push(` ${subkey}:`); + for (const [subsubkey, subsubval] of Object.entries(subval)) { + if (subsubval === null || subsubval === undefined) continue; + if (Array.isArray(subsubval)) { + if (subsubval.length === 0) { + lines.push(` ${subsubkey}: []`); + } else { + lines.push(` ${subsubkey}:`); + for (const item of subsubval) { + lines.push(` - ${item}`); + } + } + } else { + lines.push(` ${subsubkey}: ${subsubval}`); + } + } + } else { + const sv = String(subval); + lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`); + } + } + } else { + const sv = String(value); + if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) { + lines.push(`${key}: "${sv}"`); + } else { + lines.push(`${key}: ${sv}`); + } + } + } + return lines.join('\n'); +} + +function spliceFrontmatter(content, newObj) { + const yamlStr = reconstructFrontmatter(newObj); + const match = content.match(/^---\n[\s\S]+?\n---/); + if (match) { + return `---\n${yamlStr}\n---` + content.slice(match[0].length); + } + return `---\n${yamlStr}\n---\n\n` + content; +} + +function parseMustHavesBlock(content, blockName) { + // Extract a specific block from must_haves in raw frontmatter YAML + // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}] + const fmMatch = content.match(/^---\n([\s\S]+?)\n---/); + if (!fmMatch) return []; + + const yaml = fmMatch[1]; + // Find the block (e.g., "truths:", "artifacts:", "key_links:") + const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm'); + const blockStart = yaml.search(blockPattern); + if (blockStart === -1) return []; + + const afterBlock = yaml.slice(blockStart); + const blockLines = afterBlock.split('\n').slice(1); // skip the header line + + const items = []; + let current = null; + + for (const line of blockLines) { + // Stop at same or lower indent level (non-continuation) + if (line.trim() === '') continue; + const indent = line.match(/^(\s*)/)[1].length; + if (indent <= 4 && line.trim() !== '') break; // back to must_haves level or higher + + if (line.match(/^\s{6}-\s+/)) { + // New list item at 6-space indent + if (current) items.push(current); + current = {}; + // Check if it's a simple string item + const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/); + if (simpleMatch && !line.includes(':')) { + current = simpleMatch[1]; + } else { + // Key-value on same line as dash: "- path: value" + const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + current = {}; + current[kvMatch[1]] = kvMatch[2]; + } + } + } else if (current && typeof current === 'object') { + // Continuation key-value at 8+ space indent + const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + const val = kvMatch[2]; + // Try to parse as number + current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; + } + // Array items under a key + const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/); + if (arrMatch) { + // Find the last key added and convert to array + const keys = Object.keys(current); + const lastKey = keys[keys.length - 1]; + if (lastKey && !Array.isArray(current[lastKey])) { + current[lastKey] = current[lastKey] ? [current[lastKey]] : []; + } + if (lastKey) current[lastKey].push(arrMatch[1]); + } + } + } + if (current) items.push(current); + + return items; +} + +// ─── Frontmatter CRUD commands ──────────────────────────────────────────────── + +const FRONTMATTER_SCHEMAS = { + plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] }, + summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] }, + verification: { required: ['phase', 'verified', 'status', 'score'] }, +}; + +function cmdFrontmatterGet(cwd, filePath, field, raw) { + if (!filePath) { error('file path required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + const fm = extractFrontmatter(content); + if (field) { + const value = fm[field]; + if (value === undefined) { output({ error: 'Field not found', field }, raw); return; } + output({ [field]: value }, raw, JSON.stringify(value)); + } else { + output(fm, raw); + } +} + +function cmdFrontmatterSet(cwd, filePath, field, value, raw) { + if (!filePath || !field || value === undefined) { error('file, field, and value required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; } + const content = fs.readFileSync(fullPath, 'utf-8'); + const fm = extractFrontmatter(content); + let parsedValue; + try { parsedValue = JSON.parse(value); } catch { parsedValue = value; } + fm[field] = parsedValue; + const newContent = spliceFrontmatter(content, fm); + fs.writeFileSync(fullPath, newContent, 'utf-8'); + output({ updated: true, field, value: parsedValue }, raw, 'true'); +} + +function cmdFrontmatterMerge(cwd, filePath, data, raw) { + if (!filePath || !data) { error('file and data required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; } + const content = fs.readFileSync(fullPath, 'utf-8'); + const fm = extractFrontmatter(content); + let mergeData; + try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; } + Object.assign(fm, mergeData); + const newContent = spliceFrontmatter(content, fm); + fs.writeFileSync(fullPath, newContent, 'utf-8'); + output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true'); +} + +function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) { + if (!filePath || !schemaName) { error('file and schema required'); } + const schema = FRONTMATTER_SCHEMAS[schemaName]; + if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + const fm = extractFrontmatter(content); + const missing = schema.required.filter(f => fm[f] === undefined); + const present = schema.required.filter(f => fm[f] !== undefined); + output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid'); +} + +module.exports = { + extractFrontmatter, + reconstructFrontmatter, + spliceFrontmatter, + parseMustHavesBlock, + FRONTMATTER_SCHEMAS, + cmdFrontmatterGet, + cmdFrontmatterSet, + cmdFrontmatterMerge, + cmdFrontmatterValidate, +}; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs new file mode 100644 index 000000000..d249d738d --- /dev/null +++ b/get-shit-done/bin/lib/init.cjs @@ -0,0 +1,693 @@ +/** + * Init — Compound init commands for workflow bootstrapping + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, output, error } = require('./core.cjs'); + +function cmdInitExecutePhase(cwd, phase, raw) { + if (!phase) { + error('phase required for init execute-phase'); + } + + const config = loadConfig(cwd); + const phaseInfo = findPhaseInternal(cwd, phase); + const milestone = getMilestoneInfo(cwd); + + const result = { + // Models + executor_model: resolveModelInternal(cwd, 'gsd-executor'), + verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), + + // Config flags + commit_docs: config.commit_docs, + parallelization: config.parallelization, + branching_strategy: config.branching_strategy, + phase_branch_template: config.phase_branch_template, + milestone_branch_template: config.milestone_branch_template, + verifier_enabled: config.verifier, + + // Phase info + phase_found: !!phaseInfo, + phase_dir: phaseInfo?.directory || null, + phase_number: phaseInfo?.phase_number || null, + phase_name: phaseInfo?.phase_name || null, + phase_slug: phaseInfo?.phase_slug || null, + + // Plan inventory + plans: phaseInfo?.plans || [], + summaries: phaseInfo?.summaries || [], + incomplete_plans: phaseInfo?.incomplete_plans || [], + plan_count: phaseInfo?.plans?.length || 0, + incomplete_count: phaseInfo?.incomplete_plans?.length || 0, + + // Branch name (pre-computed) + branch_name: config.branching_strategy === 'phase' && phaseInfo + ? config.phase_branch_template + .replace('{phase}', phaseInfo.phase_number) + .replace('{slug}', phaseInfo.phase_slug || 'phase') + : config.branching_strategy === 'milestone' + ? config.milestone_branch_template + .replace('{milestone}', milestone.version) + .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone') + : null, + + // Milestone info + milestone_version: milestone.version, + milestone_name: milestone.name, + milestone_slug: generateSlugInternal(milestone.name), + + // File existence + state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + config_exists: pathExistsInternal(cwd, '.planning/config.json'), + // File paths + state_path: '.planning/STATE.md', + roadmap_path: '.planning/ROADMAP.md', + config_path: '.planning/config.json', + }; + + output(result, raw); +} + +function cmdInitPlanPhase(cwd, phase, raw) { + if (!phase) { + error('phase required for init plan-phase'); + } + + const config = loadConfig(cwd); + const phaseInfo = findPhaseInternal(cwd, phase); + + const result = { + // Models + researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'), + planner_model: resolveModelInternal(cwd, 'gsd-planner'), + checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), + + // Workflow flags + research_enabled: config.research, + plan_checker_enabled: config.plan_checker, + commit_docs: config.commit_docs, + + // Phase info + phase_found: !!phaseInfo, + phase_dir: phaseInfo?.directory || null, + phase_number: phaseInfo?.phase_number || null, + phase_name: phaseInfo?.phase_name || null, + phase_slug: phaseInfo?.phase_slug || null, + padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null, + + // Existing artifacts + has_research: phaseInfo?.has_research || false, + has_context: phaseInfo?.has_context || false, + has_plans: (phaseInfo?.plans?.length || 0) > 0, + plan_count: phaseInfo?.plans?.length || 0, + + // Environment + planning_exists: pathExistsInternal(cwd, '.planning'), + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + + // File paths + state_path: '.planning/STATE.md', + roadmap_path: '.planning/ROADMAP.md', + requirements_path: '.planning/REQUIREMENTS.md', + }; + + if (phaseInfo?.directory) { + // Find *-CONTEXT.md in phase directory + const phaseDirFull = path.join(cwd, phaseInfo.directory); + try { + const files = fs.readdirSync(phaseDirFull); + const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + if (contextFile) { + result.context_path = path.join(phaseInfo.directory, contextFile); + } + const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + if (researchFile) { + result.research_path = path.join(phaseInfo.directory, researchFile); + } + const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); + if (verificationFile) { + result.verification_path = path.join(phaseInfo.directory, verificationFile); + } + const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); + if (uatFile) { + result.uat_path = path.join(phaseInfo.directory, uatFile); + } + } catch {} + } + + output(result, raw); +} + +function cmdInitNewProject(cwd, raw) { + const config = loadConfig(cwd); + + // Detect Brave Search API key availability + const homedir = require('os').homedir(); + const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); + const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); + + // Detect existing code + let hasCode = false; + let hasPackageFile = false; + try { + const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + hasCode = files.trim().length > 0; + } catch {} + + hasPackageFile = pathExistsInternal(cwd, 'package.json') || + pathExistsInternal(cwd, 'requirements.txt') || + pathExistsInternal(cwd, 'Cargo.toml') || + pathExistsInternal(cwd, 'go.mod') || + pathExistsInternal(cwd, 'Package.swift'); + + const result = { + // Models + researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), + synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), + roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), + + // Config + commit_docs: config.commit_docs, + + // Existing state + project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), + has_codebase_map: pathExistsInternal(cwd, '.planning/codebase'), + planning_exists: pathExistsInternal(cwd, '.planning'), + + // Brownfield detection + has_existing_code: hasCode, + has_package_file: hasPackageFile, + is_brownfield: hasCode || hasPackageFile, + needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'), + + // Git state + has_git: pathExistsInternal(cwd, '.git'), + + // Enhanced search + brave_search_available: hasBraveSearch, + + // File paths + project_path: '.planning/PROJECT.md', + }; + + output(result, raw); +} + +function cmdInitNewMilestone(cwd, raw) { + const config = loadConfig(cwd); + const milestone = getMilestoneInfo(cwd); + + const result = { + // Models + researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), + synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), + roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), + + // Config + commit_docs: config.commit_docs, + research_enabled: config.research, + + // Current milestone + current_milestone: milestone.version, + current_milestone_name: milestone.name, + + // File existence + project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), + + // File paths + project_path: '.planning/PROJECT.md', + roadmap_path: '.planning/ROADMAP.md', + state_path: '.planning/STATE.md', + }; + + output(result, raw); +} + +function cmdInitQuick(cwd, description, raw) { + const config = loadConfig(cwd); + const now = new Date(); + const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null; + + // Find next quick task number + const quickDir = path.join(cwd, '.planning', 'quick'); + let nextNum = 1; + try { + const existing = fs.readdirSync(quickDir) + .filter(f => /^\d+-/.test(f)) + .map(f => parseInt(f.split('-')[0], 10)) + .filter(n => !isNaN(n)); + if (existing.length > 0) { + nextNum = Math.max(...existing) + 1; + } + } catch {} + + const result = { + // Models + planner_model: resolveModelInternal(cwd, 'gsd-planner'), + executor_model: resolveModelInternal(cwd, 'gsd-executor'), + checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), + verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), + + // Config + commit_docs: config.commit_docs, + + // Quick task info + next_num: nextNum, + slug: slug, + description: description || null, + + // Timestamps + date: now.toISOString().split('T')[0], + timestamp: now.toISOString(), + + // Paths + quick_dir: '.planning/quick', + task_dir: slug ? `.planning/quick/${nextNum}-${slug}` : null, + + // File existence + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + planning_exists: pathExistsInternal(cwd, '.planning'), + + }; + + output(result, raw); +} + +function cmdInitResume(cwd, raw) { + const config = loadConfig(cwd); + + // Check for interrupted agent + let interruptedAgentId = null; + try { + interruptedAgentId = fs.readFileSync(path.join(cwd, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); + } catch {} + + const result = { + // File existence + state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), + planning_exists: pathExistsInternal(cwd, '.planning'), + + // File paths + state_path: '.planning/STATE.md', + roadmap_path: '.planning/ROADMAP.md', + project_path: '.planning/PROJECT.md', + + // Agent state + has_interrupted_agent: !!interruptedAgentId, + interrupted_agent_id: interruptedAgentId, + + // Config + commit_docs: config.commit_docs, + }; + + output(result, raw); +} + +function cmdInitVerifyWork(cwd, phase, raw) { + if (!phase) { + error('phase required for init verify-work'); + } + + const config = loadConfig(cwd); + const phaseInfo = findPhaseInternal(cwd, phase); + + const result = { + // Models + planner_model: resolveModelInternal(cwd, 'gsd-planner'), + checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), + + // Config + commit_docs: config.commit_docs, + + // Phase info + phase_found: !!phaseInfo, + phase_dir: phaseInfo?.directory || null, + phase_number: phaseInfo?.phase_number || null, + phase_name: phaseInfo?.phase_name || null, + + // Existing artifacts + has_verification: phaseInfo?.has_verification || false, + }; + + output(result, raw); +} + +function cmdInitPhaseOp(cwd, phase, raw) { + const config = loadConfig(cwd); + let phaseInfo = findPhaseInternal(cwd, phase); + + // Fallback to ROADMAP.md if no directory exists (e.g., Plans: TBD) + if (!phaseInfo) { + const roadmapPhase = getRoadmapPhaseInternal(cwd, phase); + if (roadmapPhase?.found) { + const phaseName = roadmapPhase.phase_name; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + }; + } + } + + const result = { + // Config + commit_docs: config.commit_docs, + brave_search: config.brave_search, + + // Phase info + phase_found: !!phaseInfo, + phase_dir: phaseInfo?.directory || null, + phase_number: phaseInfo?.phase_number || null, + phase_name: phaseInfo?.phase_name || null, + phase_slug: phaseInfo?.phase_slug || null, + padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null, + + // Existing artifacts + has_research: phaseInfo?.has_research || false, + has_context: phaseInfo?.has_context || false, + has_plans: (phaseInfo?.plans?.length || 0) > 0, + has_verification: phaseInfo?.has_verification || false, + plan_count: phaseInfo?.plans?.length || 0, + + // File existence + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + planning_exists: pathExistsInternal(cwd, '.planning'), + + // File paths + state_path: '.planning/STATE.md', + roadmap_path: '.planning/ROADMAP.md', + requirements_path: '.planning/REQUIREMENTS.md', + }; + + if (phaseInfo?.directory) { + const phaseDirFull = path.join(cwd, phaseInfo.directory); + try { + const files = fs.readdirSync(phaseDirFull); + const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + if (contextFile) { + result.context_path = path.join(phaseInfo.directory, contextFile); + } + const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + if (researchFile) { + result.research_path = path.join(phaseInfo.directory, researchFile); + } + const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); + if (verificationFile) { + result.verification_path = path.join(phaseInfo.directory, verificationFile); + } + const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); + if (uatFile) { + result.uat_path = path.join(phaseInfo.directory, uatFile); + } + } catch {} + } + + output(result, raw); +} + +function cmdInitTodos(cwd, area, raw) { + const config = loadConfig(cwd); + const now = new Date(); + + // List todos (reuse existing logic) + const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); + let count = 0; + const todos = []; + + try { + const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md')); + for (const file of files) { + try { + const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8'); + const createdMatch = content.match(/^created:\s*(.+)$/m); + const titleMatch = content.match(/^title:\s*(.+)$/m); + const areaMatch = content.match(/^area:\s*(.+)$/m); + const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; + + if (area && todoArea !== area) continue; + + count++; + todos.push({ + file, + created: createdMatch ? createdMatch[1].trim() : 'unknown', + title: titleMatch ? titleMatch[1].trim() : 'Untitled', + area: todoArea, + path: path.join('.planning', 'todos', 'pending', file), + }); + } catch {} + } + } catch {} + + const result = { + // Config + commit_docs: config.commit_docs, + + // Timestamps + date: now.toISOString().split('T')[0], + timestamp: now.toISOString(), + + // Todo inventory + todo_count: count, + todos, + area_filter: area || null, + + // Paths + pending_dir: '.planning/todos/pending', + completed_dir: '.planning/todos/completed', + + // File existence + planning_exists: pathExistsInternal(cwd, '.planning'), + todos_dir_exists: pathExistsInternal(cwd, '.planning/todos'), + pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'), + }; + + output(result, raw); +} + +function cmdInitMilestoneOp(cwd, raw) { + const config = loadConfig(cwd); + const milestone = getMilestoneInfo(cwd); + + // Count phases + let phaseCount = 0; + let completedPhases = 0; + const phasesDir = path.join(cwd, '.planning', 'phases'); + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + phaseCount = dirs.length; + + // Count phases with summaries (completed) + for (const dir of dirs) { + try { + const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); + const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (hasSummary) completedPhases++; + } catch {} + } + } catch {} + + // Check archive + const archiveDir = path.join(cwd, '.planning', 'archive'); + let archivedMilestones = []; + try { + archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch {} + + const result = { + // Config + commit_docs: config.commit_docs, + + // Current milestone + milestone_version: milestone.version, + milestone_name: milestone.name, + milestone_slug: generateSlugInternal(milestone.name), + + // Phase counts + phase_count: phaseCount, + completed_phases: completedPhases, + all_phases_complete: phaseCount > 0 && phaseCount === completedPhases, + + // Archive + archived_milestones: archivedMilestones, + archive_count: archivedMilestones.length, + + // File existence + project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), + archive_exists: pathExistsInternal(cwd, '.planning/archive'), + phases_dir_exists: pathExistsInternal(cwd, '.planning/phases'), + }; + + output(result, raw); +} + +function cmdInitMapCodebase(cwd, raw) { + const config = loadConfig(cwd); + + // Check for existing codebase maps + const codebaseDir = path.join(cwd, '.planning', 'codebase'); + let existingMaps = []; + try { + existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md')); + } catch {} + + const result = { + // Models + mapper_model: resolveModelInternal(cwd, 'gsd-codebase-mapper'), + + // Config + commit_docs: config.commit_docs, + search_gitignored: config.search_gitignored, + parallelization: config.parallelization, + + // Paths + codebase_dir: '.planning/codebase', + + // Existing maps + existing_maps: existingMaps, + has_maps: existingMaps.length > 0, + + // File existence + planning_exists: pathExistsInternal(cwd, '.planning'), + codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'), + }; + + output(result, raw); +} + +function cmdInitProgress(cwd, raw) { + const config = loadConfig(cwd); + const milestone = getMilestoneInfo(cwd); + + // Analyze phases + const phasesDir = path.join(cwd, '.planning', 'phases'); + const phases = []; + let currentPhase = null; + let nextPhase = null; + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + + for (const dir of dirs) { + const match = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); + const phaseNumber = match ? match[1] : dir; + const phaseName = match && match[2] ? match[2] : null; + + const phasePath = path.join(phasesDir, dir); + const phaseFiles = fs.readdirSync(phasePath); + + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + + const status = summaries.length >= plans.length && plans.length > 0 ? 'complete' : + plans.length > 0 ? 'in_progress' : + hasResearch ? 'researched' : 'pending'; + + const phaseInfo = { + number: phaseNumber, + name: phaseName, + directory: path.join('.planning', 'phases', dir), + status, + plan_count: plans.length, + summary_count: summaries.length, + has_research: hasResearch, + }; + + phases.push(phaseInfo); + + // Find current (first incomplete with plans) and next (first pending) + if (!currentPhase && (status === 'in_progress' || status === 'researched')) { + currentPhase = phaseInfo; + } + if (!nextPhase && status === 'pending') { + nextPhase = phaseInfo; + } + } + } catch {} + + // Check for paused work + let pausedAt = null; + try { + const state = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8'); + const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/); + if (pauseMatch) pausedAt = pauseMatch[1].trim(); + } catch {} + + const result = { + // Models + executor_model: resolveModelInternal(cwd, 'gsd-executor'), + planner_model: resolveModelInternal(cwd, 'gsd-planner'), + + // Config + commit_docs: config.commit_docs, + + // Milestone + milestone_version: milestone.version, + milestone_name: milestone.name, + + // Phase overview + phases, + phase_count: phases.length, + completed_count: phases.filter(p => p.status === 'complete').length, + in_progress_count: phases.filter(p => p.status === 'in_progress').length, + + // Current state + current_phase: currentPhase, + next_phase: nextPhase, + paused_at: pausedAt, + has_work_in_progress: !!currentPhase, + + // File existence + project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), + roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), + // File paths + state_path: '.planning/STATE.md', + roadmap_path: '.planning/ROADMAP.md', + project_path: '.planning/PROJECT.md', + config_path: '.planning/config.json', + }; + + output(result, raw); +} + +module.exports = { + cmdInitExecutePhase, + cmdInitPlanPhase, + cmdInitNewProject, + cmdInitNewMilestone, + cmdInitQuick, + cmdInitResume, + cmdInitVerifyWork, + cmdInitPhaseOp, + cmdInitTodos, + cmdInitMilestoneOp, + cmdInitMapCodebase, + cmdInitProgress, +}; diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs new file mode 100644 index 000000000..dce072567 --- /dev/null +++ b/get-shit-done/bin/lib/milestone.cjs @@ -0,0 +1,215 @@ +/** + * Milestone — Milestone and requirements lifecycle operations + */ + +const fs = require('fs'); +const path = require('path'); +const { output, error } = require('./core.cjs'); +const { extractFrontmatter } = require('./frontmatter.cjs'); + +function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) { + if (!reqIdsRaw || reqIdsRaw.length === 0) { + error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02'); + } + + // Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02] + const reqIds = reqIdsRaw + .join(' ') + .replace(/[\[\]]/g, '') + .split(/[,\s]+/) + .map(r => r.trim()) + .filter(Boolean); + + if (reqIds.length === 0) { + error('no valid requirement IDs found'); + } + + const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); + if (!fs.existsSync(reqPath)) { + output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file'); + return; + } + + let reqContent = fs.readFileSync(reqPath, 'utf-8'); + const updated = []; + const notFound = []; + + for (const reqId of reqIds) { + let found = false; + + // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID** + const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi'); + if (checkboxPattern.test(reqContent)) { + reqContent = reqContent.replace(checkboxPattern, '$1x$2'); + found = true; + } + + // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete | + const tablePattern = new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'); + if (tablePattern.test(reqContent)) { + // Re-read since test() advances lastIndex for global regex + reqContent = reqContent.replace( + new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'), + '$1 Complete $2' + ); + found = true; + } + + if (found) { + updated.push(reqId); + } else { + notFound.push(reqId); + } + } + + if (updated.length > 0) { + fs.writeFileSync(reqPath, reqContent, 'utf-8'); + } + + output({ + updated: updated.length > 0, + marked_complete: updated, + not_found: notFound, + total: reqIds.length, + }, raw, `${updated.length}/${reqIds.length} requirements marked complete`); +} + +function cmdMilestoneComplete(cwd, version, options, raw) { + if (!version) { + error('version required for milestone complete (e.g., v1.0)'); + } + + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); + const statePath = path.join(cwd, '.planning', 'STATE.md'); + const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md'); + const archiveDir = path.join(cwd, '.planning', 'milestones'); + const phasesDir = path.join(cwd, '.planning', 'phases'); + const today = new Date().toISOString().split('T')[0]; + const milestoneName = options.name || version; + + // Ensure archive directory exists + fs.mkdirSync(archiveDir, { recursive: true }); + + // Gather stats from phases + let phaseCount = 0; + let totalPlans = 0; + let totalTasks = 0; + const accomplishments = []; + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + + for (const dir of dirs) { + phaseCount++; + const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + totalPlans += plans.length; + + // Extract one-liners from summaries + for (const s of summaries) { + try { + const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8'); + const fm = extractFrontmatter(content); + if (fm['one-liner']) { + accomplishments.push(fm['one-liner']); + } + // Count tasks + const taskMatches = content.match(/##\s*Task\s*\d+/gi) || []; + totalTasks += taskMatches.length; + } catch {} + } + } + } catch {} + + // Archive ROADMAP.md + if (fs.existsSync(roadmapPath)) { + const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8'); + } + + // Archive REQUIREMENTS.md + if (fs.existsSync(reqPath)) { + const reqContent = fs.readFileSync(reqPath, 'utf-8'); + const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`; + fs.writeFileSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8'); + } + + // Archive audit file if exists + const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`); + if (fs.existsSync(auditFile)) { + fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)); + } + + // Create/append MILESTONES.md entry + const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n'); + const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`; + + if (fs.existsSync(milestonesPath)) { + const existing = fs.readFileSync(milestonesPath, 'utf-8'); + fs.writeFileSync(milestonesPath, existing + '\n' + milestoneEntry, 'utf-8'); + } else { + fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8'); + } + + // Update STATE.md + if (fs.existsSync(statePath)) { + let stateContent = fs.readFileSync(statePath, 'utf-8'); + stateContent = stateContent.replace( + /(\*\*Status:\*\*\s*).*/, + `$1${version} milestone complete` + ); + stateContent = stateContent.replace( + /(\*\*Last Activity:\*\*\s*).*/, + `$1${today}` + ); + stateContent = stateContent.replace( + /(\*\*Last Activity Description:\*\*\s*).*/, + `$1${version} milestone completed and archived` + ); + fs.writeFileSync(statePath, stateContent, 'utf-8'); + } + + // Archive phase directories if requested + let phasesArchived = false; + if (options.archivePhases) { + try { + const phaseArchiveDir = path.join(archiveDir, `${version}-phases`); + fs.mkdirSync(phaseArchiveDir, { recursive: true }); + + const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name); + for (const dir of phaseDirNames) { + fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir)); + } + phasesArchived = phaseDirNames.length > 0; + } catch {} + } + + const result = { + version, + name: milestoneName, + date: today, + phases: phaseCount, + plans: totalPlans, + tasks: totalTasks, + accomplishments, + archived: { + roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)), + requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)), + audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)), + phases: phasesArchived, + }, + milestones_updated: true, + state_updated: fs.existsSync(statePath), + }; + + output(result, raw); +} + +module.exports = { + cmdRequirementsMarkComplete, + cmdMilestoneComplete, +}; diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs new file mode 100644 index 000000000..d5e6946c6 --- /dev/null +++ b/get-shit-done/bin/lib/phase.cjs @@ -0,0 +1,877 @@ +/** + * Phase — Phase CRUD, query, and lifecycle operations + */ + +const fs = require('fs'); +const path = require('path'); +const { normalizePhaseName, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, output, error } = require('./core.cjs'); +const { extractFrontmatter } = require('./frontmatter.cjs'); + +function cmdPhasesList(cwd, options, raw) { + const phasesDir = path.join(cwd, '.planning', 'phases'); + const { type, phase, includeArchived } = options; + + // If no phases directory, return empty + if (!fs.existsSync(phasesDir)) { + if (type) { + output({ files: [], count: 0 }, raw, ''); + } else { + output({ directories: [], count: 0 }, raw, ''); + } + return; + } + + try { + // Get all phase directories + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + let dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + + // Include archived phases if requested + if (includeArchived) { + const archived = getArchivedPhaseDirs(cwd); + for (const a of archived) { + dirs.push(`${a.name} [${a.milestone}]`); + } + } + + // Sort numerically (handles decimals: 01, 02, 02.1, 02.2, 03) + dirs.sort((a, b) => { + const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); + const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); + return aNum - bNum; + }); + + // If filtering by phase number + if (phase) { + const normalized = normalizePhaseName(phase); + const match = dirs.find(d => d.startsWith(normalized)); + if (!match) { + output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, ''); + return; + } + dirs = [match]; + } + + // If listing files of a specific type + if (type) { + const files = []; + for (const dir of dirs) { + const dirPath = path.join(phasesDir, dir); + const dirFiles = fs.readdirSync(dirPath); + + let filtered; + if (type === 'plans') { + filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + } else if (type === 'summaries') { + filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + } else { + filtered = dirFiles; + } + + files.push(...filtered.sort()); + } + + const result = { + files, + count: files.length, + phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)?-?/, '') : null, + }; + output(result, raw, files.join('\n')); + return; + } + + // Default: list directories + output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n')); + } catch (e) { + error('Failed to list phases: ' + e.message); + } +} + +function cmdPhaseNextDecimal(cwd, basePhase, raw) { + const phasesDir = path.join(cwd, '.planning', 'phases'); + const normalized = normalizePhaseName(basePhase); + + // Check if phases directory exists + if (!fs.existsSync(phasesDir)) { + output( + { + found: false, + base_phase: normalized, + next: `${normalized}.1`, + existing: [], + }, + raw, + `${normalized}.1` + ); + return; + } + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + + // Check if base phase exists + const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized); + + // Find existing decimal phases for this base + const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`); + const existingDecimals = []; + + for (const dir of dirs) { + const match = dir.match(decimalPattern); + if (match) { + existingDecimals.push(`${normalized}.${match[1]}`); + } + } + + // Sort numerically + existingDecimals.sort((a, b) => { + const aNum = parseFloat(a); + const bNum = parseFloat(b); + return aNum - bNum; + }); + + // Calculate next decimal + let nextDecimal; + if (existingDecimals.length === 0) { + nextDecimal = `${normalized}.1`; + } else { + const lastDecimal = existingDecimals[existingDecimals.length - 1]; + const lastNum = parseInt(lastDecimal.split('.')[1], 10); + nextDecimal = `${normalized}.${lastNum + 1}`; + } + + output( + { + found: baseExists, + base_phase: normalized, + next: nextDecimal, + existing: existingDecimals, + }, + raw, + nextDecimal + ); + } catch (e) { + error('Failed to calculate next decimal phase: ' + e.message); + } +} + +function cmdFindPhase(cwd, phase, raw) { + if (!phase) { + error('phase identifier required'); + } + + const phasesDir = path.join(cwd, '.planning', 'phases'); + const normalized = normalizePhaseName(phase); + + const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] }; + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + + const match = dirs.find(d => d.startsWith(normalized)); + if (!match) { + output(notFound, raw, ''); + return; + } + + const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/); + const phaseNumber = dirMatch ? dirMatch[1] : normalized; + const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; + + const phaseDir = path.join(phasesDir, match); + const phaseFiles = fs.readdirSync(phaseDir); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); + + const result = { + found: true, + directory: path.join('.planning', 'phases', match), + phase_number: phaseNumber, + phase_name: phaseName, + plans, + summaries, + }; + + output(result, raw, result.directory); + } catch { + output(notFound, raw, ''); + } +} + +function cmdPhasePlanIndex(cwd, phase, raw) { + if (!phase) { + error('phase required for phase-plan-index'); + } + + const phasesDir = path.join(cwd, '.planning', 'phases'); + const normalized = normalizePhaseName(phase); + + // Find phase directory + let phaseDir = null; + let phaseDirName = null; + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + const match = dirs.find(d => d.startsWith(normalized)); + if (match) { + phaseDir = path.join(phasesDir, match); + phaseDirName = match; + } + } catch { + // phases dir doesn't exist + } + + if (!phaseDir) { + output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw); + return; + } + + // Get all files in phase directory + const phaseFiles = fs.readdirSync(phaseDir); + const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); + const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + // Build set of plan IDs with summaries + const completedPlanIds = new Set( + summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) + ); + + const plans = []; + const waves = {}; + const incomplete = []; + let hasCheckpoints = false; + + for (const planFile of planFiles) { + const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', ''); + const planPath = path.join(phaseDir, planFile); + const content = fs.readFileSync(planPath, 'utf-8'); + const fm = extractFrontmatter(content); + + // Count tasks (## Task N patterns) + const taskMatches = content.match(/##\s*Task\s*\d+/gi) || []; + const taskCount = taskMatches.length; + + // Parse wave as integer + const wave = parseInt(fm.wave, 10) || 1; + + // Parse autonomous (default true if not specified) + let autonomous = true; + if (fm.autonomous !== undefined) { + autonomous = fm.autonomous === 'true' || fm.autonomous === true; + } + + if (!autonomous) { + hasCheckpoints = true; + } + + // Parse files-modified + let filesModified = []; + if (fm['files-modified']) { + filesModified = Array.isArray(fm['files-modified']) ? fm['files-modified'] : [fm['files-modified']]; + } + + const hasSummary = completedPlanIds.has(planId); + if (!hasSummary) { + incomplete.push(planId); + } + + const plan = { + id: planId, + wave, + autonomous, + objective: fm.objective || null, + files_modified: filesModified, + task_count: taskCount, + has_summary: hasSummary, + }; + + plans.push(plan); + + // Group by wave + const waveKey = String(wave); + if (!waves[waveKey]) { + waves[waveKey] = []; + } + waves[waveKey].push(planId); + } + + const result = { + phase: normalized, + plans, + waves, + incomplete, + has_checkpoints: hasCheckpoints, + }; + + output(result, raw); +} + +function cmdPhaseAdd(cwd, description, raw) { + if (!description) { + error('description required for phase add'); + } + + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + if (!fs.existsSync(roadmapPath)) { + error('ROADMAP.md not found'); + } + + const content = fs.readFileSync(roadmapPath, 'utf-8'); + const slug = generateSlugInternal(description); + + // Find highest integer phase number + const phasePattern = /#{2,4}\s*Phase\s+(\d+)(?:\.\d+)?:/gi; + let maxPhase = 0; + let m; + while ((m = phasePattern.exec(content)) !== null) { + const num = parseInt(m[1], 10); + if (num > maxPhase) maxPhase = num; + } + + const newPhaseNum = maxPhase + 1; + const paddedNum = String(newPhaseNum).padStart(2, '0'); + const dirName = `${paddedNum}-${slug}`; + const dirPath = path.join(cwd, '.planning', 'phases', dirName); + + // Create directory with .gitkeep so git tracks empty folders + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); + + // Build phase entry + const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseNum} to break down)\n`; + + // Find insertion point: before last "---" or at end + let updatedContent; + const lastSeparator = content.lastIndexOf('\n---'); + if (lastSeparator > 0) { + updatedContent = content.slice(0, lastSeparator) + phaseEntry + content.slice(lastSeparator); + } else { + updatedContent = content + phaseEntry; + } + + fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); + + const result = { + phase_number: newPhaseNum, + padded: paddedNum, + name: description, + slug, + directory: `.planning/phases/${dirName}`, + }; + + output(result, raw, paddedNum); +} + +function cmdPhaseInsert(cwd, afterPhase, description, raw) { + if (!afterPhase || !description) { + error('after-phase and description required for phase insert'); + } + + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + if (!fs.existsSync(roadmapPath)) { + error('ROADMAP.md not found'); + } + + const content = fs.readFileSync(roadmapPath, 'utf-8'); + const slug = generateSlugInternal(description); + + // Normalize input then strip leading zeros for flexible matching + const normalizedAfter = normalizePhaseName(afterPhase); + const unpadded = normalizedAfter.replace(/^0+/, ''); + const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); + const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); + if (!targetPattern.test(content)) { + error(`Phase ${afterPhase} not found in ROADMAP.md`); + } + + // Calculate next decimal using existing logic + const phasesDir = path.join(cwd, '.planning', 'phases'); + const normalizedBase = normalizePhaseName(afterPhase); + let existingDecimals = []; + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`); + for (const dir of dirs) { + const dm = dir.match(decimalPattern); + if (dm) existingDecimals.push(parseInt(dm[1], 10)); + } + } catch {} + + const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; + const decimalPhase = `${normalizedBase}.${nextDecimal}`; + const dirName = `${decimalPhase}-${slug}`; + const dirPath = path.join(cwd, '.planning', 'phases', dirName); + + // Create directory with .gitkeep so git tracks empty folders + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); + + // Build phase entry + const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`; + + // Insert after the target phase section + const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); + const headerMatch = content.match(headerPattern); + if (!headerMatch) { + error(`Could not find Phase ${afterPhase} header`); + } + + const headerIdx = content.indexOf(headerMatch[0]); + const afterHeader = content.slice(headerIdx + headerMatch[0].length); + const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i); + + let insertIdx; + if (nextPhaseMatch) { + insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index; + } else { + insertIdx = content.length; + } + + const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx); + fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); + + const result = { + phase_number: decimalPhase, + after_phase: afterPhase, + name: description, + slug, + directory: `.planning/phases/${dirName}`, + }; + + output(result, raw, decimalPhase); +} + +function cmdPhaseRemove(cwd, targetPhase, options, raw) { + if (!targetPhase) { + error('phase number required for phase remove'); + } + + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const phasesDir = path.join(cwd, '.planning', 'phases'); + const force = options.force || false; + + if (!fs.existsSync(roadmapPath)) { + error('ROADMAP.md not found'); + } + + // Normalize the target + const normalized = normalizePhaseName(targetPhase); + const isDecimal = targetPhase.includes('.'); + + // Find and validate target directory + let targetDir = null; + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized); + } catch {} + + // Check for executed work (SUMMARY.md files) + if (targetDir && !force) { + const targetPath = path.join(phasesDir, targetDir); + const files = fs.readdirSync(targetPath); + const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (summaries.length > 0) { + error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`); + } + } + + // Delete target directory + if (targetDir) { + fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true }); + } + + // Renumber subsequent phases + const renamedDirs = []; + const renamedFiles = []; + + if (isDecimal) { + // Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2) + const baseParts = normalized.split('.'); + const baseInt = baseParts[0]; + const removedDecimal = parseInt(baseParts[1], 10); + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + + // Find sibling decimals with higher numbers + const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); + const toRename = []; + for (const dir of dirs) { + const dm = dir.match(decPattern); + if (dm && parseInt(dm[1], 10) > removedDecimal) { + toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] }); + } + } + + // Sort descending to avoid conflicts + toRename.sort((a, b) => b.oldDecimal - a.oldDecimal); + + for (const item of toRename) { + const newDecimal = item.oldDecimal - 1; + const oldPhaseId = `${baseInt}.${item.oldDecimal}`; + const newPhaseId = `${baseInt}.${newDecimal}`; + const newDirName = `${baseInt}.${newDecimal}-${item.slug}`; + + // Rename directory + fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + + // Rename files inside + const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); + for (const f of dirFiles) { + // Files may have phase prefix like "06.2-01-PLAN.md" + if (f.includes(oldPhaseId)) { + const newFileName = f.replace(oldPhaseId, newPhaseId); + fs.renameSync( + path.join(phasesDir, newDirName, f), + path.join(phasesDir, newDirName, newFileName) + ); + renamedFiles.push({ from: f, to: newFileName }); + } + } + } + } catch {} + + } else { + // Integer removal: renumber all subsequent integer phases + const removedInt = parseInt(normalized, 10); + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + + // Collect directories that need renumbering (integer phases > removed, and their decimals) + const toRename = []; + for (const dir of dirs) { + const dm = dir.match(/^(\d+)(?:\.(\d+))?-(.+)$/); + if (!dm) continue; + const dirInt = parseInt(dm[1], 10); + if (dirInt > removedInt) { + toRename.push({ + dir, + oldInt: dirInt, + decimal: dm[2] ? parseInt(dm[2], 10) : null, + slug: dm[3], + }); + } + } + + // Sort descending to avoid conflicts + toRename.sort((a, b) => { + if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt; + return (b.decimal || 0) - (a.decimal || 0); + }); + + for (const item of toRename) { + const newInt = item.oldInt - 1; + const newPadded = String(newInt).padStart(2, '0'); + const oldPadded = String(item.oldInt).padStart(2, '0'); + const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : ''; + const oldPrefix = `${oldPadded}${decimalSuffix}`; + const newPrefix = `${newPadded}${decimalSuffix}`; + const newDirName = `${newPrefix}-${item.slug}`; + + // Rename directory + fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + + // Rename files inside + const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); + for (const f of dirFiles) { + if (f.startsWith(oldPrefix)) { + const newFileName = newPrefix + f.slice(oldPrefix.length); + fs.renameSync( + path.join(phasesDir, newDirName, f), + path.join(phasesDir, newDirName, newFileName) + ); + renamedFiles.push({ from: f, to: newFileName }); + } + } + } + } catch {} + } + + // Update ROADMAP.md + let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + + // Remove the target phase section + const targetEscaped = targetPhase.replace(/\./g, '\\.'); + const sectionPattern = new RegExp( + `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, + 'i' + ); + roadmapContent = roadmapContent.replace(sectionPattern, ''); + + // Remove from phase list (checkbox) + const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi'); + roadmapContent = roadmapContent.replace(checkboxPattern, ''); + + // Remove from progress table + const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'); + roadmapContent = roadmapContent.replace(tableRowPattern, ''); + + // Renumber references in ROADMAP for subsequent phases + if (!isDecimal) { + const removedInt = parseInt(normalized, 10); + + // Collect all integer phases > removedInt + const maxPhase = 99; // reasonable upper bound + for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) { + const newNum = oldNum - 1; + const oldStr = String(oldNum); + const newStr = String(newNum); + const oldPad = oldStr.padStart(2, '0'); + const newPad = newStr.padStart(2, '0'); + + // Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17: + roadmapContent = roadmapContent.replace( + new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), + `$1${newStr}$2` + ); + + // Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:** + roadmapContent = roadmapContent.replace( + new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), + `$1${newStr}$2` + ); + + // Plan references: 18-01 → 17-01 + roadmapContent = roadmapContent.replace( + new RegExp(`${oldPad}-(\\d{2})`, 'g'), + `${newPad}-$1` + ); + + // Table rows: | 18. → | 17. + roadmapContent = roadmapContent.replace( + new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), + `$1${newStr}. ` + ); + + // Depends on references + roadmapContent = roadmapContent.replace( + new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), + `$1${newStr}` + ); + } + } + + fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); + + // Update STATE.md phase count + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (fs.existsSync(statePath)) { + let stateContent = fs.readFileSync(statePath, 'utf-8'); + // Update "Total Phases" field + const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/; + const totalMatch = stateContent.match(totalPattern); + if (totalMatch) { + const oldTotal = parseInt(totalMatch[2], 10); + stateContent = stateContent.replace(totalPattern, `$1${oldTotal - 1}`); + } + // Update "Phase: X of Y" pattern + const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i; + const ofMatch = stateContent.match(ofPattern); + if (ofMatch) { + const oldTotal = parseInt(ofMatch[2], 10); + stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`); + } + fs.writeFileSync(statePath, stateContent, 'utf-8'); + } + + const result = { + removed: targetPhase, + directory_deleted: targetDir || null, + renamed_directories: renamedDirs, + renamed_files: renamedFiles, + roadmap_updated: true, + state_updated: fs.existsSync(statePath), + }; + + output(result, raw); +} + +function cmdPhaseComplete(cwd, phaseNum, raw) { + if (!phaseNum) { + error('phase number required for phase complete'); + } + + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const statePath = path.join(cwd, '.planning', 'STATE.md'); + const phasesDir = path.join(cwd, '.planning', 'phases'); + const normalized = normalizePhaseName(phaseNum); + const today = new Date().toISOString().split('T')[0]; + + // Verify phase info + const phaseInfo = findPhaseInternal(cwd, phaseNum); + if (!phaseInfo) { + error(`Phase ${phaseNum} not found`); + } + + const planCount = phaseInfo.plans.length; + const summaryCount = phaseInfo.summaries.length; + + // Update ROADMAP.md: mark phase complete + if (fs.existsSync(roadmapPath)) { + let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + + // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE) + const checkboxPattern = new RegExp( + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}[:\\s][^\\n]*)`, + 'i' + ); + roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`); + + // Progress table: update Status to Complete, add date + const phaseEscaped = phaseNum.replace('.', '\\.'); + const tablePattern = new RegExp( + `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`, + 'i' + ); + roadmapContent = roadmapContent.replace( + tablePattern, + `$1 Complete $2 ${today} $3` + ); + + // Update plan count in phase section + const planCountPattern = new RegExp( + `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + 'i' + ); + roadmapContent = roadmapContent.replace( + planCountPattern, + `$1${summaryCount}/${planCount} plans complete` + ); + + fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); + + // Update REQUIREMENTS.md traceability for this phase's requirements + const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); + if (fs.existsSync(reqPath)) { + // Extract Requirements line from roadmap for this phase + const reqMatch = roadmapContent.match( + new RegExp(`Phase\\s+${phaseNum.replace('.', '\\.')}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i') + ); + + if (reqMatch) { + const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean); + let reqContent = fs.readFileSync(reqPath, 'utf-8'); + + for (const reqId of reqIds) { + // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID** + reqContent = reqContent.replace( + new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi'), + '$1x$2' + ); + // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete | + reqContent = reqContent.replace( + new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'), + '$1 Complete $2' + ); + } + + fs.writeFileSync(reqPath, reqContent, 'utf-8'); + } + } + } + + // Find next phase + let nextPhaseNum = null; + let nextPhaseName = null; + let isLastPhase = true; + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + const currentFloat = parseFloat(phaseNum); + + // Find the next phase directory after current + for (const dir of dirs) { + const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); + if (dm) { + const dirFloat = parseFloat(dm[1]); + if (dirFloat > currentFloat) { + nextPhaseNum = dm[1]; + nextPhaseName = dm[2] || null; + isLastPhase = false; + break; + } + } + } + } catch {} + + // Update STATE.md + if (fs.existsSync(statePath)) { + let stateContent = fs.readFileSync(statePath, 'utf-8'); + + // Update Current Phase + stateContent = stateContent.replace( + /(\*\*Current Phase:\*\*\s*).*/, + `$1${nextPhaseNum || phaseNum}` + ); + + // Update Current Phase Name + if (nextPhaseName) { + stateContent = stateContent.replace( + /(\*\*Current Phase Name:\*\*\s*).*/, + `$1${nextPhaseName.replace(/-/g, ' ')}` + ); + } + + // Update Status + stateContent = stateContent.replace( + /(\*\*Status:\*\*\s*).*/, + `$1${isLastPhase ? 'Milestone complete' : 'Ready to plan'}` + ); + + // Update Current Plan + stateContent = stateContent.replace( + /(\*\*Current Plan:\*\*\s*).*/, + `$1Not started` + ); + + // Update Last Activity + stateContent = stateContent.replace( + /(\*\*Last Activity:\*\*\s*).*/, + `$1${today}` + ); + + // Update Last Activity Description + stateContent = stateContent.replace( + /(\*\*Last Activity Description:\*\*\s*).*/, + `$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}` + ); + + fs.writeFileSync(statePath, stateContent, 'utf-8'); + } + + const result = { + completed_phase: phaseNum, + phase_name: phaseInfo.phase_name, + plans_executed: `${summaryCount}/${planCount}`, + next_phase: nextPhaseNum, + next_phase_name: nextPhaseName, + is_last_phase: isLastPhase, + date: today, + roadmap_updated: fs.existsSync(roadmapPath), + state_updated: fs.existsSync(statePath), + }; + + output(result, raw); +} + +module.exports = { + cmdPhasesList, + cmdPhaseNextDecimal, + cmdFindPhase, + cmdPhasePlanIndex, + cmdPhaseAdd, + cmdPhaseInsert, + cmdPhaseRemove, + cmdPhaseComplete, +}; diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs new file mode 100644 index 000000000..fd4f4f3e7 --- /dev/null +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -0,0 +1,298 @@ +/** + * Roadmap — Roadmap parsing and update operations + */ + +const fs = require('fs'); +const path = require('path'); +const { normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs'); + +function cmdRoadmapGetPhase(cwd, phaseNum, raw) { + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + + if (!fs.existsSync(roadmapPath)) { + output({ found: false, error: 'ROADMAP.md not found' }, raw, ''); + return; + } + + try { + const content = fs.readFileSync(roadmapPath, 'utf-8'); + + // Escape special regex chars in phase number, handle decimal + const escapedPhase = phaseNum.replace(/\./g, '\\.'); + + // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name + const phasePattern = new RegExp( + `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, + 'i' + ); + const headerMatch = content.match(phasePattern); + + if (!headerMatch) { + // Fallback: check if phase exists in summary list but missing detail section + const checklistPattern = new RegExp( + `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, + 'i' + ); + const checklistMatch = content.match(checklistPattern); + + if (checklistMatch) { + // Phase exists in summary but missing detail section - malformed ROADMAP + output({ + found: false, + phase_number: phaseNum, + phase_name: checklistMatch[1].trim(), + error: 'malformed_roadmap', + message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.` + }, raw, ''); + return; + } + + output({ found: false, phase_number: phaseNum }, raw, ''); + return; + } + + const phaseName = headerMatch[1].trim(); + const headerIndex = headerMatch.index; + + // Find the end of this section (next ## or ### phase header, or end of file) + const restOfContent = content.slice(headerIndex); + const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); + const sectionEnd = nextHeaderMatch + ? headerIndex + nextHeaderMatch.index + : content.length; + + const section = content.slice(headerIndex, sectionEnd).trim(); + + // Extract goal if present + const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); + const goal = goalMatch ? goalMatch[1].trim() : null; + + // Extract success criteria as structured array + const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i); + const success_criteria = criteriaMatch + ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean) + : []; + + output( + { + found: true, + phase_number: phaseNum, + phase_name: phaseName, + goal, + success_criteria, + section, + }, + raw, + section + ); + } catch (e) { + error('Failed to read ROADMAP.md: ' + e.message); + } +} + +function cmdRoadmapAnalyze(cwd, raw) { + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + + if (!fs.existsSync(roadmapPath)) { + output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw); + return; + } + + const content = fs.readFileSync(roadmapPath, 'utf-8'); + const phasesDir = path.join(cwd, '.planning', 'phases'); + + // Extract all phase headings: ## Phase N: Name or ### Phase N: Name + const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:\s*([^\n]+)/gi; + const phases = []; + let match; + + while ((match = phasePattern.exec(content)) !== null) { + const phaseNum = match[1]; + const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim(); + + // Extract goal from the section + const sectionStart = match.index; + const restOfContent = content.slice(sectionStart); + const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); + const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length; + const section = content.slice(sectionStart, sectionEnd); + + const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); + const goal = goalMatch ? goalMatch[1].trim() : null; + + const dependsMatch = section.match(/\*\*Depends on:\*\*\s*([^\n]+)/i); + const depends_on = dependsMatch ? dependsMatch[1].trim() : null; + + // Check completion on disk + const normalized = normalizePhaseName(phaseNum); + let diskStatus = 'no_directory'; + let planCount = 0; + let summaryCount = 0; + let hasContext = false; + let hasResearch = false; + + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized); + + if (dirMatch) { + const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch)); + planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; + summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; + hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + + if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; + else if (summaryCount > 0) diskStatus = 'partial'; + else if (planCount > 0) diskStatus = 'planned'; + else if (hasResearch) diskStatus = 'researched'; + else if (hasContext) diskStatus = 'discussed'; + else diskStatus = 'empty'; + } + } catch {} + + // Check ROADMAP checkbox status + const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}`, 'i'); + const checkboxMatch = content.match(checkboxPattern); + const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false; + + phases.push({ + number: phaseNum, + name: phaseName, + goal, + depends_on, + plan_count: planCount, + summary_count: summaryCount, + has_context: hasContext, + has_research: hasResearch, + disk_status: diskStatus, + roadmap_complete: roadmapComplete, + }); + } + + // Extract milestone info + const milestones = []; + const milestonePattern = /##\s*(.*v(\d+\.\d+)[^(\n]*)/gi; + let mMatch; + while ((mMatch = milestonePattern.exec(content)) !== null) { + milestones.push({ + heading: mMatch[1].trim(), + version: 'v' + mMatch[2], + }); + } + + // Find current and next phase + const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null; + const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null; + + // Aggregated stats + const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0); + const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0); + const completedPhases = phases.filter(p => p.disk_status === 'complete').length; + + // Detect phases in summary list without detail sections (malformed ROADMAP) + const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+(?:\.\d+)?)/gi; + const checklistPhases = new Set(); + let checklistMatch; + while ((checklistMatch = checklistPattern.exec(content)) !== null) { + checklistPhases.add(checklistMatch[1]); + } + const detailPhases = new Set(phases.map(p => p.number)); + const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p)); + + const result = { + milestones, + phases, + phase_count: phases.length, + completed_phases: completedPhases, + total_plans: totalPlans, + total_summaries: totalSummaries, + progress_percent: totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0, + current_phase: currentPhase ? currentPhase.number : null, + next_phase: nextPhase ? nextPhase.number : null, + missing_phase_details: missingDetails.length > 0 ? missingDetails : null, + }; + + output(result, raw); +} + +function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { + if (!phaseNum) { + error('phase number required for roadmap update-plan-progress'); + } + + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + + const phaseInfo = findPhaseInternal(cwd, phaseNum); + if (!phaseInfo) { + error(`Phase ${phaseNum} not found`); + } + + const planCount = phaseInfo.plans.length; + const summaryCount = phaseInfo.summaries.length; + + if (planCount === 0) { + output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans'); + return; + } + + const isComplete = summaryCount >= planCount; + const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned'; + const today = new Date().toISOString().split('T')[0]; + + if (!fs.existsSync(roadmapPath)) { + output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap'); + return; + } + + let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + const phaseEscaped = phaseNum.replace('.', '\\.'); + + // Progress table row: update Plans column (summaries/plans) and Status column + const tablePattern = new RegExp( + `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`, + 'i' + ); + const dateField = isComplete ? ` ${today} ` : ' '; + roadmapContent = roadmapContent.replace( + tablePattern, + `$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4` + ); + + // Update plan count in phase detail section + const planCountPattern = new RegExp( + `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + 'i' + ); + const planCountText = isComplete + ? `${summaryCount}/${planCount} plans complete` + : `${summaryCount}/${planCount} plans executed`; + roadmapContent = roadmapContent.replace(planCountPattern, `$1${planCountText}`); + + // If complete: check checkbox + if (isComplete) { + const checkboxPattern = new RegExp( + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, + 'i' + ); + roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`); + } + + fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); + + output({ + updated: true, + phase: phaseNum, + plan_count: planCount, + summary_count: summaryCount, + status, + complete: isComplete, + }, raw, `${summaryCount}/${planCount} ${status}`); +} + +module.exports = { + cmdRoadmapGetPhase, + cmdRoadmapAnalyze, + cmdRoadmapUpdatePlanProgress, +}; diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs new file mode 100644 index 000000000..039574439 --- /dev/null +++ b/get-shit-done/bin/lib/state.cjs @@ -0,0 +1,490 @@ +/** + * State — STATE.md operations and progression engine + */ + +const fs = require('fs'); +const path = require('path'); +const { loadConfig, output, error } = require('./core.cjs'); + +function cmdStateLoad(cwd, raw) { + const config = loadConfig(cwd); + const planningDir = path.join(cwd, '.planning'); + + let stateRaw = ''; + try { + stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8'); + } catch {} + + const configExists = fs.existsSync(path.join(planningDir, 'config.json')); + const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md')); + const stateExists = stateRaw.length > 0; + + const result = { + config, + state_raw: stateRaw, + state_exists: stateExists, + roadmap_exists: roadmapExists, + config_exists: configExists, + }; + + // For --raw, output a condensed key=value format + if (raw) { + const c = config; + const lines = [ + `model_profile=${c.model_profile}`, + `commit_docs=${c.commit_docs}`, + `branching_strategy=${c.branching_strategy}`, + `phase_branch_template=${c.phase_branch_template}`, + `milestone_branch_template=${c.milestone_branch_template}`, + `parallelization=${c.parallelization}`, + `research=${c.research}`, + `plan_checker=${c.plan_checker}`, + `verifier=${c.verifier}`, + `config_exists=${configExists}`, + `roadmap_exists=${roadmapExists}`, + `state_exists=${stateExists}`, + ]; + process.stdout.write(lines.join('\n')); + process.exit(0); + } + + output(result); +} + +function cmdStateGet(cwd, section, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + try { + const content = fs.readFileSync(statePath, 'utf-8'); + + if (!section) { + output({ content }, raw, content); + return; + } + + // Try to find markdown section or field + const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // Check for **field:** value + const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i'); + const fieldMatch = content.match(fieldPattern); + if (fieldMatch) { + output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim()); + return; + } + + // Check for ## Section + const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i'); + const sectionMatch = content.match(sectionPattern); + if (sectionMatch) { + output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim()); + return; + } + + output({ error: `Section or field "${section}" not found` }, raw, ''); + } catch { + error('STATE.md not found'); + } +} + +function cmdStatePatch(cwd, patches, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + try { + let content = fs.readFileSync(statePath, 'utf-8'); + const results = { updated: [], failed: [] }; + + for (const [field, value] of Object.entries(patches)) { + const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); + + if (pattern.test(content)) { + content = content.replace(pattern, `$1${value}`); + results.updated.push(field); + } else { + results.failed.push(field); + } + } + + if (results.updated.length > 0) { + fs.writeFileSync(statePath, content, 'utf-8'); + } + + output(results, raw, results.updated.length > 0 ? 'true' : 'false'); + } catch { + error('STATE.md not found'); + } +} + +function cmdStateUpdate(cwd, field, value) { + if (!field || value === undefined) { + error('field and value required for state update'); + } + + const statePath = path.join(cwd, '.planning', 'STATE.md'); + try { + let content = fs.readFileSync(statePath, 'utf-8'); + const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); + if (pattern.test(content)) { + content = content.replace(pattern, `$1${value}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ updated: true }); + } else { + output({ updated: false, reason: `Field "${field}" not found in STATE.md` }); + } + } catch { + output({ updated: false, reason: 'STATE.md not found' }); + } +} + +// ─── State Progression Engine ──────────────────────────────────────────────── + +function stateExtractField(content, fieldName) { + const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); + const match = content.match(pattern); + return match ? match[1].trim() : null; +} + +function stateReplaceField(content, fieldName, newValue) { + const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'); + if (pattern.test(content)) { + return content.replace(pattern, `$1${newValue}`); + } + return null; +} + +function cmdStateAdvancePlan(cwd, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10); + const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10); + const today = new Date().toISOString().split('T')[0]; + + if (isNaN(currentPlan) || isNaN(totalPlans)) { + output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw); + return; + } + + if (currentPlan >= totalPlans) { + content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content; + content = stateReplaceField(content, 'Last Activity', today) || content; + fs.writeFileSync(statePath, content, 'utf-8'); + output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false'); + } else { + const newPlan = currentPlan + 1; + content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content; + content = stateReplaceField(content, 'Status', 'Ready to execute') || content; + content = stateReplaceField(content, 'Last Activity', today) || content; + fs.writeFileSync(statePath, content, 'utf-8'); + output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true'); + } +} + +function cmdStateRecordMetric(cwd, options, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const { phase, plan, duration, tasks, files } = options; + + if (!phase || !plan || !duration) { + output({ error: 'phase, plan, and duration required' }, raw); + return; + } + + // Find Performance Metrics section and its table + const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; + const metricsMatch = content.match(metricsPattern); + + if (metricsMatch) { + const tableHeader = metricsMatch[1]; + let tableBody = metricsMatch[2].trimEnd(); + const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`; + + if (tableBody.trim() === '' || tableBody.includes('None yet')) { + tableBody = newRow; + } else { + tableBody = tableBody + '\n' + newRow; + } + + content = content.replace(metricsPattern, `${tableHeader}${tableBody}\n`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ recorded: true, phase, plan, duration }, raw, 'true'); + } else { + output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateUpdateProgress(cwd, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + + // Count summaries across all phases + const phasesDir = path.join(cwd, '.planning', 'phases'); + let totalPlans = 0; + let totalSummaries = 0; + + if (fs.existsSync(phasesDir)) { + const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()).map(e => e.name); + for (const dir of phaseDirs) { + const files = fs.readdirSync(path.join(phasesDir, dir)); + totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length; + totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length; + } + } + + const percent = totalPlans > 0 ? Math.round(totalSummaries / totalPlans * 100) : 0; + const barWidth = 10; + const filled = Math.round(percent / 100 * barWidth); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); + const progressStr = `[${bar}] ${percent}%`; + + const progressPattern = /(\*\*Progress:\*\*\s*).*/i; + if (progressPattern.test(content)) { + content = content.replace(progressPattern, `$1${progressStr}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr); + } else { + output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateAddDecision(cwd, options, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + const { phase, summary, rationale } = options; + if (!summary) { output({ error: 'summary required' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const entry = `- [Phase ${phase || '?'}]: ${summary}${rationale ? ` — ${rationale}` : ''}`; + + // Find Decisions section (various heading patterns) + const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + let sectionBody = match[2]; + // Remove placeholders + sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); + sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; + content = content.replace(sectionPattern, `${match[1]}${sectionBody}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ added: true, decision: entry }, raw, 'true'); + } else { + output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateAddBlocker(cwd, text, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + if (!text) { output({ error: 'text required' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const entry = `- ${text}`; + + const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + let sectionBody = match[2]; + sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); + sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; + content = content.replace(sectionPattern, `${match[1]}${sectionBody}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ added: true, blocker: text }, raw, 'true'); + } else { + output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateResolveBlocker(cwd, text, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + if (!text) { output({ error: 'text required' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + + const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + const sectionBody = match[2]; + const lines = sectionBody.split('\n'); + const filtered = lines.filter(line => { + if (!line.startsWith('- ')) return true; + return !line.toLowerCase().includes(text.toLowerCase()); + }); + + let newBody = filtered.join('\n'); + // If section is now empty, add placeholder + if (!newBody.trim() || !newBody.includes('- ')) { + newBody = 'None\n'; + } + + content = content.replace(sectionPattern, `${match[1]}${newBody}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ resolved: true, blocker: text }, raw, 'true'); + } else { + output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateRecordSession(cwd, options, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const now = new Date().toISOString(); + const updated = []; + + // Update Last session / Last Date + let result = stateReplaceField(content, 'Last session', now); + if (result) { content = result; updated.push('Last session'); } + result = stateReplaceField(content, 'Last Date', now); + if (result) { content = result; updated.push('Last Date'); } + + // Update Stopped at + if (options.stopped_at) { + result = stateReplaceField(content, 'Stopped At', options.stopped_at); + if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at); + if (result) { content = result; updated.push('Stopped At'); } + } + + // Update Resume file + const resumeFile = options.resume_file || 'None'; + result = stateReplaceField(content, 'Resume File', resumeFile); + if (!result) result = stateReplaceField(content, 'Resume file', resumeFile); + if (result) { content = result; updated.push('Resume File'); } + + if (updated.length > 0) { + fs.writeFileSync(statePath, content, 'utf-8'); + output({ recorded: true, updated }, raw, 'true'); + } else { + output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateSnapshot(cwd, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + + if (!fs.existsSync(statePath)) { + output({ error: 'STATE.md not found' }, raw); + return; + } + + const content = fs.readFileSync(statePath, 'utf-8'); + + // Helper to extract **Field:** value patterns + const extractField = (fieldName) => { + const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); + const match = content.match(pattern); + return match ? match[1].trim() : null; + }; + + // Extract basic fields + const currentPhase = extractField('Current Phase'); + const currentPhaseName = extractField('Current Phase Name'); + const totalPhasesRaw = extractField('Total Phases'); + const currentPlan = extractField('Current Plan'); + const totalPlansRaw = extractField('Total Plans in Phase'); + const status = extractField('Status'); + const progressRaw = extractField('Progress'); + const lastActivity = extractField('Last Activity'); + const lastActivityDesc = extractField('Last Activity Description'); + const pausedAt = extractField('Paused At'); + + // Parse numeric fields + const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; + const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; + const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null; + + // Extract decisions table + const decisions = []; + const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); + if (decisionsMatch) { + const tableBody = decisionsMatch[1]; + const rows = tableBody.trim().split('\n').filter(r => r.includes('|')); + for (const row of rows) { + const cells = row.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 3) { + decisions.push({ + phase: cells[0], + summary: cells[1], + rationale: cells[2], + }); + } + } + } + + // Extract blockers list + const blockers = []; + const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); + if (blockersMatch) { + const blockersSection = blockersMatch[1]; + const items = blockersSection.match(/^-\s+(.+)$/gm) || []; + for (const item of items) { + blockers.push(item.replace(/^-\s+/, '').trim()); + } + } + + // Extract session info + const session = { + last_date: null, + stopped_at: null, + resume_file: null, + }; + + const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + if (sessionMatch) { + const sessionSection = sessionMatch[1]; + const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i); + const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i); + const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i); + + if (lastDateMatch) session.last_date = lastDateMatch[1].trim(); + if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim(); + if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim(); + } + + const result = { + current_phase: currentPhase, + current_phase_name: currentPhaseName, + total_phases: totalPhases, + current_plan: currentPlan, + total_plans_in_phase: totalPlansInPhase, + status, + progress_percent: progressPercent, + last_activity: lastActivity, + last_activity_desc: lastActivityDesc, + decisions, + blockers, + paused_at: pausedAt, + session, + }; + + output(result, raw); +} + +module.exports = { + stateExtractField, + stateReplaceField, + cmdStateLoad, + cmdStateGet, + cmdStatePatch, + cmdStateUpdate, + cmdStateAdvancePlan, + cmdStateRecordMetric, + cmdStateUpdateProgress, + cmdStateAddDecision, + cmdStateAddBlocker, + cmdStateResolveBlocker, + cmdStateRecordSession, + cmdStateSnapshot, +}; diff --git a/get-shit-done/bin/lib/template.cjs b/get-shit-done/bin/lib/template.cjs new file mode 100644 index 000000000..02f3bad7b --- /dev/null +++ b/get-shit-done/bin/lib/template.cjs @@ -0,0 +1,222 @@ +/** + * Template — Template selection and fill operations + */ + +const fs = require('fs'); +const path = require('path'); +const { normalizePhaseName, findPhaseInternal, generateSlugInternal, output, error } = require('./core.cjs'); +const { reconstructFrontmatter } = require('./frontmatter.cjs'); + +function cmdTemplateSelect(cwd, planPath, raw) { + if (!planPath) { + error('plan-path required'); + } + + try { + const fullPath = path.join(cwd, planPath); + const content = fs.readFileSync(fullPath, 'utf-8'); + + // Simple heuristics + const taskMatch = content.match(/###\s*Task\s*\d+/g) || []; + const taskCount = taskMatch.length; + + const decisionMatch = content.match(/decision/gi) || []; + const hasDecisions = decisionMatch.length > 0; + + // Count file mentions + const fileMentions = new Set(); + const filePattern = /`([^`]+\.[a-zA-Z]+)`/g; + let m; + while ((m = filePattern.exec(content)) !== null) { + if (m[1].includes('/') && !m[1].startsWith('http')) { + fileMentions.add(m[1]); + } + } + const fileCount = fileMentions.size; + + let template = 'templates/summary-standard.md'; + let type = 'standard'; + + if (taskCount <= 2 && fileCount <= 3 && !hasDecisions) { + template = 'templates/summary-minimal.md'; + type = 'minimal'; + } else if (hasDecisions || fileCount > 6 || taskCount > 5) { + template = 'templates/summary-complex.md'; + type = 'complex'; + } + + const result = { template, type, taskCount, fileCount, hasDecisions }; + output(result, raw, template); + } catch (e) { + // Fallback to standard + output({ template: 'templates/summary-standard.md', type: 'standard', error: e.message }, raw, 'templates/summary-standard.md'); + } +} + +function cmdTemplateFill(cwd, templateType, options, raw) { + if (!templateType) { error('template type required: summary, plan, or verification'); } + if (!options.phase) { error('--phase required'); } + + const phaseInfo = findPhaseInternal(cwd, options.phase); + if (!phaseInfo || !phaseInfo.found) { output({ error: 'Phase not found', phase: options.phase }, raw); return; } + + const padded = normalizePhaseName(options.phase); + const today = new Date().toISOString().split('T')[0]; + const phaseName = options.name || phaseInfo.phase_name || 'Unnamed'; + const phaseSlug = phaseInfo.phase_slug || generateSlugInternal(phaseName); + const phaseId = `${padded}-${phaseSlug}`; + const planNum = (options.plan || '01').padStart(2, '0'); + const fields = options.fields || {}; + + let frontmatter, body, fileName; + + switch (templateType) { + case 'summary': { + frontmatter = { + phase: phaseId, + plan: planNum, + subsystem: '[primary category]', + tags: [], + provides: [], + affects: [], + 'tech-stack': { added: [], patterns: [] }, + 'key-files': { created: [], modified: [] }, + 'key-decisions': [], + 'patterns-established': [], + duration: '[X]min', + completed: today, + ...fields, + }; + body = [ + `# Phase ${options.phase}: ${phaseName} Summary`, + '', + '**[Substantive one-liner describing outcome]**', + '', + '## Performance', + '- **Duration:** [time]', + '- **Tasks:** [count completed]', + '- **Files modified:** [count]', + '', + '## Accomplishments', + '- [Key outcome 1]', + '- [Key outcome 2]', + '', + '## Task Commits', + '1. **Task 1: [task name]** - `hash`', + '', + '## Files Created/Modified', + '- `path/to/file.ts` - What it does', + '', + '## Decisions & Deviations', + '[Key decisions or "None - followed plan as specified"]', + '', + '## Next Phase Readiness', + '[What\'s ready for next phase]', + ].join('\n'); + fileName = `${padded}-${planNum}-SUMMARY.md`; + break; + } + case 'plan': { + const planType = options.type || 'execute'; + const wave = parseInt(options.wave) || 1; + frontmatter = { + phase: phaseId, + plan: planNum, + type: planType, + wave, + depends_on: [], + files_modified: [], + autonomous: true, + user_setup: [], + must_haves: { truths: [], artifacts: [], key_links: [] }, + ...fields, + }; + body = [ + `# Phase ${options.phase} Plan ${planNum}: [Title]`, + '', + '## Objective', + '- **What:** [What this plan builds]', + '- **Why:** [Why it matters for the phase goal]', + '- **Output:** [Concrete deliverable]', + '', + '## Context', + '@.planning/PROJECT.md', + '@.planning/ROADMAP.md', + '@.planning/STATE.md', + '', + '## Tasks', + '', + '', + ' [Task name]', + ' [file paths]', + ' [What to do]', + ' [How to verify]', + ' [Definition of done]', + '', + '', + '## Verification', + '[How to verify this plan achieved its objective]', + '', + '## Success Criteria', + '- [ ] [Criterion 1]', + '- [ ] [Criterion 2]', + ].join('\n'); + fileName = `${padded}-${planNum}-PLAN.md`; + break; + } + case 'verification': { + frontmatter = { + phase: phaseId, + verified: new Date().toISOString(), + status: 'pending', + score: '0/0 must-haves verified', + ...fields, + }; + body = [ + `# Phase ${options.phase}: ${phaseName} — Verification`, + '', + '## Observable Truths', + '| # | Truth | Status | Evidence |', + '|---|-------|--------|----------|', + '| 1 | [Truth] | pending | |', + '', + '## Required Artifacts', + '| Artifact | Expected | Status | Details |', + '|----------|----------|--------|---------|', + '| [path] | [what] | pending | |', + '', + '## Key Link Verification', + '| From | To | Via | Status | Details |', + '|------|----|----|--------|---------|', + '| [source] | [target] | [connection] | pending | |', + '', + '## Requirements Coverage', + '| Requirement | Status | Blocking Issue |', + '|-------------|--------|----------------|', + '| [req] | pending | |', + '', + '## Result', + '[Pending verification]', + ].join('\n'); + fileName = `${padded}-VERIFICATION.md`; + break; + } + default: + error(`Unknown template type: ${templateType}. Available: summary, plan, verification`); + return; + } + + const fullContent = `---\n${reconstructFrontmatter(frontmatter)}\n---\n\n${body}\n`; + const outPath = path.join(cwd, phaseInfo.directory, fileName); + + if (fs.existsSync(outPath)) { + output({ error: 'File already exists', path: path.relative(cwd, outPath) }, raw); + return; + } + + fs.writeFileSync(outPath, fullContent, 'utf-8'); + const relPath = path.relative(cwd, outPath); + output({ created: true, path: relPath, template: templateType }, raw, relPath); +} + +module.exports = { cmdTemplateSelect, cmdTemplateFill }; diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs new file mode 100644 index 000000000..694f6f76f --- /dev/null +++ b/get-shit-done/bin/lib/verify.cjs @@ -0,0 +1,772 @@ +/** + * Verify — Verification suite, consistency, and health validation + */ + +const fs = require('fs'); +const path = require('path'); +const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, output, error } = require('./core.cjs'); +const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs'); + +function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) { + if (!summaryPath) { + error('summary-path required'); + } + + const fullPath = path.join(cwd, summaryPath); + const checkCount = checkFileCount || 2; + + // Check 1: Summary exists + if (!fs.existsSync(fullPath)) { + const result = { + passed: false, + checks: { + summary_exists: false, + files_created: { checked: 0, found: 0, missing: [] }, + commits_exist: false, + self_check: 'not_found', + }, + errors: ['SUMMARY.md not found'], + }; + output(result, raw, 'failed'); + return; + } + + const content = fs.readFileSync(fullPath, 'utf-8'); + const errors = []; + + // Check 2: Spot-check files mentioned in summary + const mentionedFiles = new Set(); + const patterns = [ + /`([^`]+\.[a-zA-Z]+)`/g, + /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi, + ]; + + for (const pattern of patterns) { + let m; + while ((m = pattern.exec(content)) !== null) { + const filePath = m[1]; + if (filePath && !filePath.startsWith('http') && filePath.includes('/')) { + mentionedFiles.add(filePath); + } + } + } + + const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount); + const missing = []; + for (const file of filesToCheck) { + if (!fs.existsSync(path.join(cwd, file))) { + missing.push(file); + } + } + + // Check 3: Commits exist + const commitHashPattern = /\b[0-9a-f]{7,40}\b/g; + const hashes = content.match(commitHashPattern) || []; + let commitsExist = false; + if (hashes.length > 0) { + for (const hash of hashes.slice(0, 3)) { + const result = execGit(cwd, ['cat-file', '-t', hash]); + if (result.exitCode === 0 && result.stdout === 'commit') { + commitsExist = true; + break; + } + } + } + + // Check 4: Self-check section + let selfCheck = 'not_found'; + const selfCheckPattern = /##\s*(?:Self[- ]?Check|Verification|Quality Check)/i; + if (selfCheckPattern.test(content)) { + const passPattern = /(?:all\s+)?(?:pass|✓|✅|complete|succeeded)/i; + const failPattern = /(?:fail|✗|❌|incomplete|blocked)/i; + const checkSection = content.slice(content.search(selfCheckPattern)); + if (failPattern.test(checkSection)) { + selfCheck = 'failed'; + } else if (passPattern.test(checkSection)) { + selfCheck = 'passed'; + } + } + + if (missing.length > 0) errors.push('Missing files: ' + missing.join(', ')); + if (!commitsExist && hashes.length > 0) errors.push('Referenced commit hashes not found in git history'); + if (selfCheck === 'failed') errors.push('Self-check section indicates failure'); + + const checks = { + summary_exists: true, + files_created: { checked: filesToCheck.length, found: filesToCheck.length - missing.length, missing }, + commits_exist: commitsExist, + self_check: selfCheck, + }; + + const passed = missing.length === 0 && selfCheck !== 'failed'; + const result = { passed, checks, errors }; + output(result, raw, passed ? 'passed' : 'failed'); +} + +function cmdVerifyPlanStructure(cwd, filePath, raw) { + if (!filePath) { error('file path required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + + const fm = extractFrontmatter(content); + const errors = []; + const warnings = []; + + // Check required frontmatter fields + const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves']; + for (const field of required) { + if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`); + } + + // Parse and check task elements + const taskPattern = /]*>([\s\S]*?)<\/task>/g; + const tasks = []; + let taskMatch; + while ((taskMatch = taskPattern.exec(content)) !== null) { + const taskContent = taskMatch[1]; + const nameMatch = taskContent.match(/([\s\S]*?)<\/name>/); + const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed'; + const hasFiles = //.test(taskContent); + const hasAction = //.test(taskContent); + const hasVerify = //.test(taskContent); + const hasDone = //.test(taskContent); + + if (!nameMatch) errors.push('Task missing element'); + if (!hasAction) errors.push(`Task '${taskName}' missing `); + if (!hasVerify) warnings.push(`Task '${taskName}' missing `); + if (!hasDone) warnings.push(`Task '${taskName}' missing `); + if (!hasFiles) warnings.push(`Task '${taskName}' missing `); + + tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone }); + } + + if (tasks.length === 0) warnings.push('No elements found'); + + // Wave/depends_on consistency + if (fm.wave && parseInt(fm.wave) > 1 && (!fm.depends_on || (Array.isArray(fm.depends_on) && fm.depends_on.length === 0))) { + warnings.push('Wave > 1 but depends_on is empty'); + } + + // Autonomous/checkpoint consistency + const hasCheckpoints = / f.match(/-PLAN\.md$/i)); + const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)); + + // Extract plan IDs (everything before -PLAN.md) + const planIds = new Set(plans.map(p => p.replace(/-PLAN\.md$/i, ''))); + const summaryIds = new Set(summaries.map(s => s.replace(/-SUMMARY\.md$/i, ''))); + + // Plans without summaries + const incompletePlans = [...planIds].filter(id => !summaryIds.has(id)); + if (incompletePlans.length > 0) { + errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`); + } + + // Summaries without plans (orphans) + const orphanSummaries = [...summaryIds].filter(id => !planIds.has(id)); + if (orphanSummaries.length > 0) { + warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`); + } + + output({ + complete: errors.length === 0, + phase: phaseInfo.phase_number, + plan_count: plans.length, + summary_count: summaries.length, + incomplete_plans: incompletePlans, + orphan_summaries: orphanSummaries, + errors, + warnings, + }, raw, errors.length === 0 ? 'complete' : 'incomplete'); +} + +function cmdVerifyReferences(cwd, filePath, raw) { + if (!filePath) { error('file path required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + + const found = []; + const missing = []; + + // Find @-references: @path/to/file (must contain / to be a file path) + const atRefs = content.match(/@([^\s\n,)]+\/[^\s\n,)]+)/g) || []; + for (const ref of atRefs) { + const cleanRef = ref.slice(1); // remove @ + const resolved = cleanRef.startsWith('~/') + ? path.join(process.env.HOME || '', cleanRef.slice(2)) + : path.join(cwd, cleanRef); + if (fs.existsSync(resolved)) { + found.push(cleanRef); + } else { + missing.push(cleanRef); + } + } + + // Find backtick file paths that look like real paths (contain / and have extension) + const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10})`/g) || []; + for (const ref of backtickRefs) { + const cleanRef = ref.slice(1, -1); // remove backticks + if (cleanRef.startsWith('http') || cleanRef.includes('${') || cleanRef.includes('{{')) continue; + if (found.includes(cleanRef) || missing.includes(cleanRef)) continue; // dedup + const resolved = path.join(cwd, cleanRef); + if (fs.existsSync(resolved)) { + found.push(cleanRef); + } else { + missing.push(cleanRef); + } + } + + output({ + valid: missing.length === 0, + found: found.length, + missing, + total: found.length + missing.length, + }, raw, missing.length === 0 ? 'valid' : 'invalid'); +} + +function cmdVerifyCommits(cwd, hashes, raw) { + if (!hashes || hashes.length === 0) { error('At least one commit hash required'); } + + const valid = []; + const invalid = []; + for (const hash of hashes) { + const result = execGit(cwd, ['cat-file', '-t', hash]); + if (result.exitCode === 0 && result.stdout.trim() === 'commit') { + valid.push(hash); + } else { + invalid.push(hash); + } + } + + output({ + all_valid: invalid.length === 0, + valid, + invalid, + total: hashes.length, + }, raw, invalid.length === 0 ? 'valid' : 'invalid'); +} + +function cmdVerifyArtifacts(cwd, planFilePath, raw) { + if (!planFilePath) { error('plan file path required'); } + const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; } + + const artifacts = parseMustHavesBlock(content, 'artifacts'); + if (artifacts.length === 0) { + output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw); + return; + } + + const results = []; + for (const artifact of artifacts) { + if (typeof artifact === 'string') continue; // skip simple string items + const artPath = artifact.path; + if (!artPath) continue; + + const artFullPath = path.join(cwd, artPath); + const exists = fs.existsSync(artFullPath); + const check = { path: artPath, exists, issues: [], passed: false }; + + if (exists) { + const fileContent = safeReadFile(artFullPath) || ''; + const lineCount = fileContent.split('\n').length; + + if (artifact.min_lines && lineCount < artifact.min_lines) { + check.issues.push(`Only ${lineCount} lines, need ${artifact.min_lines}`); + } + if (artifact.contains && !fileContent.includes(artifact.contains)) { + check.issues.push(`Missing pattern: ${artifact.contains}`); + } + if (artifact.exports) { + const exports = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports]; + for (const exp of exports) { + if (!fileContent.includes(exp)) check.issues.push(`Missing export: ${exp}`); + } + } + check.passed = check.issues.length === 0; + } else { + check.issues.push('File not found'); + } + + results.push(check); + } + + const passed = results.filter(r => r.passed).length; + output({ + all_passed: passed === results.length, + passed, + total: results.length, + artifacts: results, + }, raw, passed === results.length ? 'valid' : 'invalid'); +} + +function cmdVerifyKeyLinks(cwd, planFilePath, raw) { + if (!planFilePath) { error('plan file path required'); } + const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; } + + const keyLinks = parseMustHavesBlock(content, 'key_links'); + if (keyLinks.length === 0) { + output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw); + return; + } + + const results = []; + for (const link of keyLinks) { + if (typeof link === 'string') continue; + const check = { from: link.from, to: link.to, via: link.via || '', verified: false, detail: '' }; + + const sourceContent = safeReadFile(path.join(cwd, link.from || '')); + if (!sourceContent) { + check.detail = 'Source file not found'; + } else if (link.pattern) { + try { + const regex = new RegExp(link.pattern); + if (regex.test(sourceContent)) { + check.verified = true; + check.detail = 'Pattern found in source'; + } else { + const targetContent = safeReadFile(path.join(cwd, link.to || '')); + if (targetContent && regex.test(targetContent)) { + check.verified = true; + check.detail = 'Pattern found in target'; + } else { + check.detail = `Pattern "${link.pattern}" not found in source or target`; + } + } + } catch { + check.detail = `Invalid regex pattern: ${link.pattern}`; + } + } else { + // No pattern: just check source references target + if (sourceContent.includes(link.to || '')) { + check.verified = true; + check.detail = 'Target referenced in source'; + } else { + check.detail = 'Target not referenced in source'; + } + } + + results.push(check); + } + + const verified = results.filter(r => r.verified).length; + output({ + all_verified: verified === results.length, + verified, + total: results.length, + links: results, + }, raw, verified === results.length ? 'valid' : 'invalid'); +} + +function cmdValidateConsistency(cwd, raw) { + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const phasesDir = path.join(cwd, '.planning', 'phases'); + const errors = []; + const warnings = []; + + // Check for ROADMAP + if (!fs.existsSync(roadmapPath)) { + errors.push('ROADMAP.md not found'); + output({ passed: false, errors, warnings }, raw, 'failed'); + return; + } + + const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + + // Extract phases from ROADMAP + const roadmapPhases = new Set(); + const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi; + let m; + while ((m = phasePattern.exec(roadmapContent)) !== null) { + roadmapPhases.add(m[1]); + } + + // Get phases on disk + const diskPhases = new Set(); + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + for (const dir of dirs) { + const dm = dir.match(/^(\d+(?:\.\d+)?)/); + if (dm) diskPhases.add(dm[1]); + } + } catch {} + + // Check: phases in ROADMAP but not on disk + for (const p of roadmapPhases) { + if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) { + warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`); + } + } + + // Check: phases on disk but not in ROADMAP + for (const p of diskPhases) { + const unpadded = String(parseInt(p, 10)); + if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) { + warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`); + } + } + + // Check: sequential phase numbers (integers only) + const integerPhases = [...diskPhases] + .filter(p => !p.includes('.')) + .map(p => parseInt(p, 10)) + .sort((a, b) => a - b); + + for (let i = 1; i < integerPhases.length; i++) { + if (integerPhases[i] !== integerPhases[i - 1] + 1) { + warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`); + } + } + + // Check: plan numbering within phases + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + + for (const dir of dirs) { + const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); + + // Extract plan numbers + const planNums = plans.map(p => { + const pm = p.match(/-(\d{2})-PLAN\.md$/); + return pm ? parseInt(pm[1], 10) : null; + }).filter(n => n !== null); + + for (let i = 1; i < planNums.length; i++) { + if (planNums[i] !== planNums[i - 1] + 1) { + warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} → ${planNums[i]}`); + } + } + + // Check: plans without summaries (completed plans) + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); + const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); + const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); + + // Summary without matching plan is suspicious + for (const sid of summaryIds) { + if (!planIds.has(sid)) { + warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`); + } + } + } + } catch {} + + // Check: frontmatter in plans has required fields + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + + for (const dir of dirs) { + const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')); + + for (const plan of plans) { + const content = fs.readFileSync(path.join(phasesDir, dir, plan), 'utf-8'); + const fm = extractFrontmatter(content); + + if (!fm.wave) { + warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`); + } + } + } + } catch {} + + const passed = errors.length === 0; + output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed'); +} + +function cmdValidateHealth(cwd, options, raw) { + const planningDir = path.join(cwd, '.planning'); + const projectPath = path.join(planningDir, 'PROJECT.md'); + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + const statePath = path.join(planningDir, 'STATE.md'); + const configPath = path.join(planningDir, 'config.json'); + const phasesDir = path.join(planningDir, 'phases'); + + const errors = []; + const warnings = []; + const info = []; + const repairs = []; + + // Helper to add issue + const addIssue = (severity, code, message, fix, repairable = false) => { + const issue = { code, message, fix, repairable }; + if (severity === 'error') errors.push(issue); + else if (severity === 'warning') warnings.push(issue); + else info.push(issue); + }; + + // ─── Check 1: .planning/ exists ─────────────────────────────────────────── + if (!fs.existsSync(planningDir)) { + addIssue('error', 'E001', '.planning/ directory not found', 'Run /gsd:new-project to initialize'); + output({ + status: 'broken', + errors, + warnings, + info, + repairable_count: 0, + }, raw); + return; + } + + // ─── Check 2: PROJECT.md exists and has required sections ───────────────── + if (!fs.existsSync(projectPath)) { + addIssue('error', 'E002', 'PROJECT.md not found', 'Run /gsd:new-project to create'); + } else { + const content = fs.readFileSync(projectPath, 'utf-8'); + const requiredSections = ['## What This Is', '## Core Value', '## Requirements']; + for (const section of requiredSections) { + if (!content.includes(section)) { + addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually'); + } + } + } + + // ─── Check 3: ROADMAP.md exists ─────────────────────────────────────────── + if (!fs.existsSync(roadmapPath)) { + addIssue('error', 'E003', 'ROADMAP.md not found', 'Run /gsd:new-milestone to create roadmap'); + } + + // ─── Check 4: STATE.md exists and references valid phases ───────────────── + if (!fs.existsSync(statePath)) { + addIssue('error', 'E004', 'STATE.md not found', 'Run /gsd:health --repair to regenerate', true); + repairs.push('regenerateState'); + } else { + const stateContent = fs.readFileSync(statePath, 'utf-8'); + // Extract phase references from STATE.md + const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)?)/g)].map(m => m[1]); + // Get disk phases + const diskPhases = new Set(); + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory()) { + const m = e.name.match(/^(\d+(?:\.\d+)?)/); + if (m) diskPhases.add(m[1]); + } + } + } catch {} + // Check for invalid references + for (const ref of phaseRefs) { + const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0'); + if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) { + // Only warn if phases dir has any content (not just an empty project) + if (diskPhases.size > 0) { + addIssue('warning', 'W002', `STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`, 'Run /gsd:health --repair to regenerate STATE.md', true); + if (!repairs.includes('regenerateState')) repairs.push('regenerateState'); + } + } + } + } + + // ─── Check 5: config.json valid JSON + valid schema ─────────────────────── + if (!fs.existsSync(configPath)) { + addIssue('warning', 'W003', 'config.json not found', 'Run /gsd:health --repair to create with defaults', true); + repairs.push('createConfig'); + } else { + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw); + // Validate known fields + const validProfiles = ['quality', 'balanced', 'budget']; + if (parsed.model_profile && !validProfiles.includes(parsed.model_profile)) { + addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed.model_profile}"`, `Valid values: ${validProfiles.join(', ')}`); + } + } catch (err) { + addIssue('error', 'E005', `config.json: JSON parse error - ${err.message}`, 'Run /gsd:health --repair to reset to defaults', true); + repairs.push('resetConfig'); + } + } + + // ─── Check 6: Phase directory naming (NN-name format) ───────────────────── + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)?-[\w-]+$/)) { + addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)'); + } + } + } catch {} + + // ─── Check 7: Orphaned plans (PLAN without SUMMARY) ─────────────────────── + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (!e.isDirectory()) continue; + const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name)); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))); + + for (const plan of plans) { + const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', ''); + if (!summaryBases.has(planBase)) { + addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress'); + } + } + } + } catch {} + + // ─── Check 8: Run existing consistency checks ───────────────────────────── + // Inline subset of cmdValidateConsistency + if (fs.existsSync(roadmapPath)) { + const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + const roadmapPhases = new Set(); + const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi; + let m; + while ((m = phasePattern.exec(roadmapContent)) !== null) { + roadmapPhases.add(m[1]); + } + + const diskPhases = new Set(); + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory()) { + const dm = e.name.match(/^(\d+(?:\.\d+)?)/); + if (dm) diskPhases.add(dm[1]); + } + } + } catch {} + + // Phases in ROADMAP but not on disk + for (const p of roadmapPhases) { + const padded = String(parseInt(p, 10)).padStart(2, '0'); + if (!diskPhases.has(p) && !diskPhases.has(padded)) { + addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap'); + } + } + + // Phases on disk but not in ROADMAP + for (const p of diskPhases) { + const unpadded = String(parseInt(p, 10)); + if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) { + addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory'); + } + } + } + + // ─── Perform repairs if requested ───────────────────────────────────────── + const repairActions = []; + if (options.repair && repairs.length > 0) { + for (const repair of repairs) { + try { + switch (repair) { + case 'createConfig': + case 'resetConfig': { + const defaults = { + model_profile: 'balanced', + commit_docs: true, + search_gitignored: false, + branching_strategy: 'none', + research: true, + plan_checker: true, + verifier: true, + parallelization: true, + }; + fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); + repairActions.push({ action: repair, success: true, path: 'config.json' }); + break; + } + case 'regenerateState': { + // Create timestamped backup before overwriting + if (fs.existsSync(statePath)) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const backupPath = `${statePath}.bak-${timestamp}`; + fs.copyFileSync(statePath, backupPath); + repairActions.push({ action: 'backupState', success: true, path: backupPath }); + } + // Generate minimal STATE.md from ROADMAP.md structure + const milestone = getMilestoneInfo(cwd); + let stateContent = `# Session State\n\n`; + stateContent += `## Project Reference\n\n`; + stateContent += `See: .planning/PROJECT.md\n\n`; + stateContent += `## Position\n\n`; + stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`; + stateContent += `**Current phase:** (determining...)\n`; + stateContent += `**Status:** Resuming\n\n`; + stateContent += `## Session Log\n\n`; + stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by /gsd:health --repair\n`; + fs.writeFileSync(statePath, stateContent, 'utf-8'); + repairActions.push({ action: repair, success: true, path: 'STATE.md' }); + break; + } + } + } catch (err) { + repairActions.push({ action: repair, success: false, error: err.message }); + } + } + } + + // ─── Determine overall status ───────────────────────────────────────────── + let status; + if (errors.length > 0) { + status = 'broken'; + } else if (warnings.length > 0) { + status = 'degraded'; + } else { + status = 'healthy'; + } + + const repairableCount = errors.filter(e => e.repairable).length + + warnings.filter(w => w.repairable).length; + + output({ + status, + errors, + warnings, + info, + repairable_count: repairableCount, + repairs_performed: repairActions.length > 0 ? repairActions : undefined, + }, raw); +} + +module.exports = { + cmdVerifySummary, + cmdVerifyPlanStructure, + cmdVerifyPhaseCompleteness, + cmdVerifyReferences, + cmdVerifyCommits, + cmdVerifyArtifacts, + cmdVerifyKeyLinks, + cmdValidateConsistency, + cmdValidateHealth, +}; diff --git a/package.json b/package.json index d53bfc498..0022afbd0 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,6 @@ "scripts": { "build:hooks": "node scripts/build-hooks.js", "prepublishOnly": "npm run build:hooks", - "test": "node --test get-shit-done/bin/gsd-tools.test.js" + "test": "node --test tests/*.test.cjs" } } From 2c0db8ea8506c8e34b6f398b2c512988b81e8007 Mon Sep 17 00:00:00 2001 From: Cristian Mos Date: Fri, 20 Feb 2026 20:49:59 +0200 Subject: [PATCH 3/3] fix(gemini): prevent workflows and templates from being incorrectly converted to TOML --- bin/install.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bin/install.js b/bin/install.js index b348677d9..5f0e38935 100755 --- a/bin/install.js +++ b/bin/install.js @@ -680,7 +680,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { * @param {string} pathPrefix - Path prefix for file references * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini') */ -function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) { +function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) { const isOpencode = runtime === 'opencode'; const dirName = getDirName(runtime); @@ -697,7 +697,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) { const destPath = path.join(destDir, entry.name); if (entry.isDirectory()) { - copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime); + copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand); } else if (entry.name.endsWith('.md')) { // Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths let content = fs.readFileSync(srcPath, 'utf8'); @@ -712,12 +712,16 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) { content = convertClaudeToOpencodeFrontmatter(content); fs.writeFileSync(destPath, content); } else if (runtime === 'gemini') { - // Convert to TOML for Gemini (strip tags — terminals can't render subscript) - content = stripSubTags(content); - const tomlContent = convertClaudeToGeminiToml(content); - // Replace extension with .toml - const tomlPath = destPath.replace(/\.md$/, '.toml'); - fs.writeFileSync(tomlPath, tomlContent); + if (isCommand) { + // Convert to TOML for Gemini (strip tags — terminals can't render subscript) + content = stripSubTags(content); + const tomlContent = convertClaudeToGeminiToml(content); + // Replace extension with .toml + const tomlPath = destPath.replace(/\.md$/, '.toml'); + fs.writeFileSync(tomlPath, tomlContent); + } else { + fs.writeFileSync(destPath, content); + } } else { fs.writeFileSync(destPath, content); } @@ -1386,7 +1390,7 @@ function install(isGlobal, runtime = 'claude') { const gsdSrc = path.join(src, 'commands', 'gsd'); const gsdDest = path.join(commandsDir, 'gsd'); - copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime); + copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true); if (verifyInstalled(gsdDest, 'commands/gsd')) { console.log(` ${green}✓${reset} Installed commands/gsd`); } else {