From aab01f43e96ac81f1de506abcb47b76ac56358ed Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 20 May 2026 20:57:06 -0400 Subject: [PATCH] =?UTF-8?q?refactor(tests):=20consolidate=20Phase=20Lifecy?= =?UTF-8?q?cle=20Module=20=E2=80=94=2020=20files=20=E2=86=92=204=20(#3741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(tests): lint rule — cap test files per production module at 2 Adds scripts/lint-test-file-count.cjs with a ratcheted allowlist (scripts/lint-test-file-count.allowlist.json) capturing today's 30 violating clusters as a ceiling. New entries blocked at PR time; reductions ratchet automatically. Wires into .github/workflows/test.yml as a new step in lint-tests. Refs #3737 Co-Authored-By: Claude Sonnet 4.6 * refactor(tests): consolidate Phase Lifecycle Module — 20 files → 4 - Merge 10 CJS bug-fix test files into tests/phase.test.cjs - Merge sdk/src/phase-runner-types.test.ts + sdk/src/phase-prompt.test.ts into sdk/src/phase-runner.test.ts (97 → 152 tests, 0 failures) - Rename 4 mis-attributed test files out of phase cluster: phase-researcher-app-aware → gsd-researcher-app-aware phase-researcher-flow-diagram → gsd-researcher-flow-diagram feat-3023-phase-type-models → feat-3023-model-phase-types phase-6-cjs-sdk-seam-contracts → cjs-sdk-bridge-seam-contracts - Fix phasePlanIndex (#3430): non-canonical plan filename warning now surfaces in data.warnings[] as "Ignored noncanonical plan files: ..." instead of a separate singular data.warning field - Update allowlist: phase ceiling 20 → 4 (closes #3740) - Add Phase Lifecycle Module glossary entry to CONTEXT.md Closes #3740 Co-Authored-By: Claude Sonnet 4.6 * fix(phase-roadmap-mutation): replaceInCurrentMilestone handles active milestone inside
block When the active milestone is wrapped in a
block (e.g. user collapsed it, or milestone transition), the after-
slice is empty or contains only footer text — the pattern never matches and the replacement is silently dropped. Fix: when after.replace() produces no change, fall back to replacing inside the last
...
block. Shipped-milestone blocks are untouched because only the last
block is targeted. Closes #2641 Co-Authored-By: Claude Sonnet 4.6 * fix(changeset): add required type/pr frontmatter to 3740 fragment Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/3733-lint-test-file-count.md | 6 - .changeset/3740-consolidate-phase-tests.md | 22 + CONTEXT.md | 3 + scripts/lint-test-file-count.allowlist.json | 2 +- sdk/src/phase-prompt.test.ts | 535 ------ sdk/src/phase-runner-types.test.ts | 421 ----- sdk/src/phase-runner.test.ts | 940 ++++++++++ sdk/src/query/phase-roadmap-mutation.ts | 36 +- sdk/src/query/phase.ts | 27 +- tests/bug-1962-phase-suffix-case.test.cjs | 49 - .../bug-1998-phase-complete-checkbox.test.cjs | 137 -- .../bug-2005-phase-complete-details.test.cjs | 180 -- ...2526-phase-complete-req-discovery.test.cjs | 271 --- .../bug-3287-phase-dir-prefix-parity.test.cjs | 198 -- ...ase-dir-prefix-drift-in-workflows.test.cjs | 171 -- ...phase-complete-state-md-staleness.test.cjs | 417 ----- ...remove-preserves-decimal-sections.test.cjs | 159 -- ...emove-renumbers-slugged-plan-refs.test.cjs | 179 -- ...=> cjs-sdk-bridge-seam-contracts.test.cjs} | 0 ...s => feat-3023-model-phase-types.test.cjs} | 0 ....cjs => gsd-researcher-app-aware.test.cjs} | 0 ...s => gsd-researcher-flow-diagram.test.cjs} | 0 tests/phase-complete-auto-prune.test.cjs | 173 -- tests/phase.test.cjs | 1595 ++++++++++++++++- 24 files changed, 2602 insertions(+), 2919 deletions(-) delete mode 100644 .changeset/3733-lint-test-file-count.md create mode 100644 .changeset/3740-consolidate-phase-tests.md delete mode 100644 sdk/src/phase-prompt.test.ts delete mode 100644 sdk/src/phase-runner-types.test.ts delete mode 100644 tests/bug-1962-phase-suffix-case.test.cjs delete mode 100644 tests/bug-1998-phase-complete-checkbox.test.cjs delete mode 100644 tests/bug-2005-phase-complete-details.test.cjs delete mode 100644 tests/bug-2526-phase-complete-req-discovery.test.cjs delete mode 100644 tests/bug-3287-phase-dir-prefix-parity.test.cjs delete mode 100644 tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs delete mode 100644 tests/bug-3517-phase-complete-state-md-staleness.test.cjs delete mode 100644 tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs delete mode 100644 tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs rename tests/{phase-6-cjs-sdk-seam-contracts.test.cjs => cjs-sdk-bridge-seam-contracts.test.cjs} (100%) rename tests/{feat-3023-phase-type-models.test.cjs => feat-3023-model-phase-types.test.cjs} (100%) rename tests/{phase-researcher-app-aware.test.cjs => gsd-researcher-app-aware.test.cjs} (100%) rename tests/{phase-researcher-flow-diagram.test.cjs => gsd-researcher-flow-diagram.test.cjs} (100%) delete mode 100644 tests/phase-complete-auto-prune.test.cjs diff --git a/.changeset/3733-lint-test-file-count.md b/.changeset/3733-lint-test-file-count.md deleted file mode 100644 index 562a6ad56..000000000 --- a/.changeset/3733-lint-test-file-count.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -type: Added -pr: 3733 ---- - -**New CI lint rule `lint-test-file-count` prevents per-feature test-file proliferation** — a recurring pattern where one production module (e.g. `phase.cjs`, `init.cjs`) accumulates 5–20 separate test files over time as PRs add issue-stamped files (`bug-NNNN-*.test.cjs`, `feat-NNNN-*.test.cjs`) next to an existing primary. The rule scans `sdk/src/query/`, `sdk/src/`, `get-shit-done/bin/lib/`, and `bin/` for production modules, then counts matching test files in `tests/` and `sdk/src/**/`. Each module is capped at 2 (primary + one integration). Existing over-limit clusters are frozen in `scripts/lint-test-file-count.allowlist.json` at their current count (30 modules; `phase` is the worst at 20 files). The allowlist ratchets downward automatically — reducing a cluster is always allowed; increasing it requires a PR-description justification. Added `"lint:test-file-count"` npm script and a `Lint — test file count per module` step in the `lint-tests` CI job. diff --git a/.changeset/3740-consolidate-phase-tests.md b/.changeset/3740-consolidate-phase-tests.md new file mode 100644 index 000000000..062c353ec --- /dev/null +++ b/.changeset/3740-consolidate-phase-tests.md @@ -0,0 +1,22 @@ +--- +type: Added +pr: 3740 +--- + + +## Summary + +Consolidates the Phase Lifecycle Module test cluster from 20 files to 4, satisfying the lint-test-file-count allowlist ceiling. + +- Merged 10 small CJS bug-fix test files into `tests/phase.test.cjs` +- Merged `sdk/src/phase-runner-types.test.ts` and `sdk/src/phase-prompt.test.ts` into `sdk/src/phase-runner.test.ts` +- Renamed 4 mis-attributed test files whose production seam is not `phase.cjs`/`phase.ts`: + - `phase-researcher-app-aware` → `gsd-researcher-app-aware` + - `phase-researcher-flow-diagram` → `gsd-researcher-flow-diagram` + - `feat-3023-phase-type-models` → `feat-3023-model-phase-types` + - `phase-6-cjs-sdk-seam-contracts` → `cjs-sdk-bridge-seam-contracts` +- Fixed `phasePlanIndex` (#3430): non-canonical plan filename warning now flows through `warnings[]` array alongside other diagnostics instead of a separate singular `warning` field +- Updated `scripts/lint-test-file-count.allowlist.json`: `phase` ceiling 20 → 4 +- Added Phase Lifecycle Module glossary entry to `CONTEXT.md` + +Closes #3740 diff --git a/CONTEXT.md b/CONTEXT.md index 220e74bc8..8931d6ae5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,9 @@ Module owning `milestone complete` (archive roadmap/requirements/phases, build M ### Dispatch Pipeline Module Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. Entry point: `sdk/src/query/query-dispatch.ts`. Typed contract: `sdk/src/query/query-dispatch-contract.ts`. +### Phase Lifecycle Module +Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry points: `get-shit-done/bin/lib/phase.cjs` (CJS surface) and `sdk/src/query/phase.ts` (SDK native query). Typed events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent` (see `sdk/src/types.ts`). Phase execution is driven by `sdk/src/phase-runner.ts`; prompt construction by `sdk/src/phase-prompt.ts`. + ### Dispatch Policy Module Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract. diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index d33af199c..bdd6f9abb 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -1,7 +1,7 @@ { "_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's count as the ceiling. Reductions are ratcheted automatically — when a cluster drops to ≤ 2, remove its entry. New entries require justification in PR description.", "modules": { - "phase": { "current": 20, "issue": "TBD" }, + "phase": { "current": 4, "issue": "3740" }, "worktree": { "current": 13, "issue": "TBD" }, "milestone": { "current": 10, "issue": "TBD" }, "roadmap": { "current": 9, "issue": "TBD" }, diff --git a/sdk/src/phase-prompt.test.ts b/sdk/src/phase-prompt.test.ts deleted file mode 100644 index 6d94749bf..000000000 --- a/sdk/src/phase-prompt.test.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js'; -import { PhaseType } from './types.js'; -import type { ContextFiles, ParsedPlan, PlanFrontmatter } from './types.js'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -async function createTempDir(): Promise { - return mkdtemp(join(tmpdir(), 'gsd-prompt-')); -} - -function makeWorkflowContent(purpose: string, steps: string[]): string { - const stepBlocks = steps - .map((s, i) => `\n${s}\n`) - .join('\n\n'); - return `\n${purpose}\n\n\n\n${stepBlocks}\n`; -} - -function makeAgentDef(name: string, tools: string, role: string): string { - return `---\nname: ${name}\ntools: ${tools}\n---\n\n\n${role}\n`; -} - -function makeParsedPlan(overrides?: Partial): ParsedPlan { - return { - frontmatter: { - phase: 'execute', - plan: 'test-plan', - type: 'feature', - wave: 1, - depends_on: [], - files_modified: [], - autonomous: true, - requirements: [], - must_haves: { truths: [], artifacts: [], key_links: [] }, - } as PlanFrontmatter, - objective: 'Test objective', - execution_context: [], - context_refs: [], - tasks: [], - raw: '', - ...overrides, - }; -} - -// ─── extractBlock tests ────────────────────────────────────────────────────── - -describe('extractBlock', () => { - it('extracts content from a simple block', () => { - const content = '\nDo the thing.\n'; - expect(extractBlock(content, 'purpose')).toBe('Do the thing.'); - }); - - it('extracts content from block with attributes', () => { - const content = '\nLoad context.\n'; - expect(extractBlock(content, 'step')).toBe('Load context.'); - }); - - it('returns empty string for missing block', () => { - const content = 'Something'; - expect(extractBlock(content, 'role')).toBe(''); - }); - - it('extracts multiline content', () => { - const content = '\nLine 1\nLine 2\nLine 3\n'; - expect(extractBlock(content, 'role')).toBe('Line 1\nLine 2\nLine 3'); - }); -}); - -describe('extractSteps', () => { - it('extracts multiple steps from process content', () => { - const process = ` -Initialize -Run tasks -Check results`; - - const steps = extractSteps(process); - expect(steps).toHaveLength(3); - expect(steps[0]).toEqual({ name: 'init', content: 'Initialize' }); - expect(steps[1]).toEqual({ name: 'execute', content: 'Run tasks' }); - expect(steps[2]).toEqual({ name: 'verify', content: 'Check results' }); - }); - - it('returns empty array for no steps', () => { - expect(extractSteps('no steps here')).toEqual([]); - }); - - it('handles steps with priority attributes', () => { - const process = '\nDo first.\n'; - const steps = extractSteps(process); - expect(steps).toHaveLength(1); - expect(steps[0].name).toBe('init'); - expect(steps[0].content).toBe('Do first.'); - }); -}); - -// ─── PromptFactory tests ───────────────────────────────────────────────────── - -describe('PromptFactory', () => { - let tempDir: string; - let workflowsDir: string; - let agentsDir: string; - - beforeEach(async () => { - tempDir = await createTempDir(); - workflowsDir = join(tempDir, 'workflows'); - agentsDir = join(tempDir, 'agents'); - await mkdir(workflowsDir, { recursive: true }); - await mkdir(agentsDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - function makeFactory(): PromptFactory { - // sdkPromptsDir points to a non-existent temp subdir so real sdk/prompts/ files - // don't interfere — tests control exactly which files exist on disk. - return new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), - }); - } - - describe('buildPrompt', () => { - it('assembles research prompt with role + purpose + process + context', async () => { - await writeFile( - join(workflowsDir, 'research-phase.md'), - makeWorkflowContent('Research the phase.', ['Gather info', 'Analyze findings']), - ); - await writeFile( - join(agentsDir, 'gsd-phase-researcher.md'), - makeAgentDef('gsd-phase-researcher', 'Read, Grep, Bash', 'You are a researcher.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { - state: '# State\nproject: test', - roadmap: '# Roadmap\nphases listed', - }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - expect(prompt).toContain('## Agent Instructions'); - expect(prompt).toContain('You are a researcher.'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Research the phase.'); - expect(prompt).toContain('## Process'); - expect(prompt).toContain('Gather info'); - expect(prompt).toContain('## Context'); - expect(prompt).toContain('# State'); - expect(prompt).toContain('# Roadmap'); - - // Cache-friendly ordering (#1614): stable prefix before variable context - const agentIdx = prompt.indexOf('## Agent Instructions'); - const contextIdx = prompt.indexOf('## Context'); - expect(agentIdx).toBeLessThan(contextIdx); - }); - - it('assembles plan prompt with all context files', async () => { - await writeFile( - join(workflowsDir, 'plan-phase.md'), - makeWorkflowContent('Plan the implementation.', ['Break down tasks']), - ); - await writeFile( - join(agentsDir, 'gsd-planner.md'), - makeAgentDef('gsd-planner', 'Read, Write, Bash', 'You are a planner.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { - state: '# State', - roadmap: '# Roadmap', - context: '# Context', - research: '# Research', - requirements: '# Requirements', - }; - - const prompt = await factory.buildPrompt(PhaseType.Plan, null, contextFiles); - - expect(prompt).toContain('You are a planner.'); - expect(prompt).toContain('Plan the implementation.'); - expect(prompt).toContain('# State'); - expect(prompt).toContain('# Research'); - expect(prompt).toContain('# Requirements'); - expect(prompt).toContain('You are a planner.'); - }); - - it('delegates execute phase with plan to buildExecutorPrompt', async () => { - await writeFile( - join(agentsDir, 'gsd-executor.md'), - makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'), - ); - - const factory = makeFactory(); - const plan = makeParsedPlan({ objective: 'Build the auth system' }); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles); - - // buildExecutorPrompt produces structured output with ## Objective - expect(prompt).toContain('## Objective'); - expect(prompt).toContain('Build the auth system'); - expect(prompt).toContain('## Role'); - expect(prompt).toContain('You are an executor.'); - }); - - it('handles execute phase without plan (non-delegation path)', async () => { - await writeFile( - join(workflowsDir, 'execute-plan.md'), - makeWorkflowContent('Execute the plan.', ['Run tasks']), - ); - await writeFile( - join(agentsDir, 'gsd-executor.md'), - makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles); - - // Falls through to general assembly path - expect(prompt).toContain('## Agent Instructions'); - expect(prompt).toContain('You are an executor.'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Execute the plan.'); - }); - - it('assembles verify prompt with phase instructions', async () => { - await writeFile( - join(workflowsDir, 'verify-phase.md'), - makeWorkflowContent('Verify phase goals.', ['Check artifacts', 'Run tests']), - ); - await writeFile( - join(agentsDir, 'gsd-verifier.md'), - makeAgentDef('gsd-verifier', 'Read, Bash, Grep', 'You are a verifier.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { - state: '# State', - roadmap: '# Roadmap', - requirements: '# Requirements', - }; - - const prompt = await factory.buildPrompt(PhaseType.Verify, null, contextFiles); - - expect(prompt).toContain('You are a verifier.'); - expect(prompt).toContain('Verify phase goals.'); - expect(prompt).toContain('You are a verifier.'); - }); - - it('assembles discuss prompt without agent role (no dedicated agent)', async () => { - await writeFile( - join(workflowsDir, 'discuss-phase.md'), - makeWorkflowContent('Discuss implementation decisions.', ['Identify areas']), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles); - - // Discuss has no agent, so no Agent Instructions section - expect(prompt).not.toContain('## Agent Instructions'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Discuss implementation decisions.'); - }); - - it('handles missing workflow file gracefully', async () => { - // No workflow files on disk - await writeFile( - join(agentsDir, 'gsd-phase-researcher.md'), - makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - // Should still produce a prompt with agent instructions and context - expect(prompt).toContain('## Agent Instructions'); - expect(prompt).toContain('## Context'); - expect(prompt).not.toContain('## Purpose'); - }); - - it('handles missing agent def gracefully', async () => { - await writeFile( - join(workflowsDir, 'research-phase.md'), - makeWorkflowContent('Research the phase.', ['Gather info']), - ); - // No agent file on disk - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - expect(prompt).not.toContain('## Agent Instructions'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Research the phase.'); - }); - - it('omits empty context section when no files provided', async () => { - await writeFile( - join(workflowsDir, 'discuss-phase.md'), - makeWorkflowContent('Discuss things.', ['Talk']), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = {}; - - const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles); - - expect(prompt).not.toContain('## Context'); - }); - }); - - describe('loadWorkflowFile', () => { - it('loads existing workflow file', async () => { - await writeFile( - join(workflowsDir, 'research-phase.md'), - 'workflow content', - ); - - const factory = makeFactory(); - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBe('workflow content'); - }); - - it('returns undefined for missing workflow file', async () => { - const factory = makeFactory(); - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBeUndefined(); - }); - }); - - describe('loadAgentDef', () => { - it('loads agent def from agents dir', async () => { - await writeFile( - join(agentsDir, 'gsd-executor.md'), - 'agent content', - ); - - const factory = makeFactory(); - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('agent content'); - }); - - it('returns undefined for phases with no agent (discuss)', async () => { - const factory = makeFactory(); - const content = await factory.loadAgentDef(PhaseType.Discuss); - expect(content).toBeUndefined(); - }); - - it('falls back to project agents dir', async () => { - const projectAgentsDir = join(tempDir, 'project-agents'); - await mkdir(projectAgentsDir, { recursive: true }); - await writeFile( - join(projectAgentsDir, 'gsd-executor.md'), - 'project agent content', - ); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - projectAgentsDir, - sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('project agent content'); - }); - - it('prefers user agents dir over project agents dir', async () => { - const projectAgentsDir = join(tempDir, 'project-agents'); - await mkdir(projectAgentsDir, { recursive: true }); - await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); - await writeFile(join(projectAgentsDir, 'gsd-executor.md'), 'project agent'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - projectAgentsDir, - sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('user agent'); - }); - }); - - // ─── Headless prompt loading ───────────────────────────────────────────── - - describe('headless prompt loading', () => { - it('loadWorkflowFile prefers installed GSD over sdkPromptsDir', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'workflows'), { recursive: true }); - - // Write both: installed GSD and SDK bundled version - await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original'); - await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK bundled version'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBe('GSD-1 original'); - }); - - it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'workflows'), { recursive: true }); - - // Only GSD-1 original exists, no SDK version - await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBe('GSD-1 original'); - }); - - it('loadAgentDef prefers installed agents over sdkPromptsDir', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'agents'), { recursive: true }); - - // Write both: installed agent and SDK bundled agent - await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); - await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK bundled agent'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('user agent'); - }); - - it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'agents'), { recursive: true }); - - // Only user agent exists, no SDK version - await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('user agent'); - }); - - it('buildPrompt sanitizes interactive patterns from output', async () => { - // Use separate lines so non-interactive content survives stripping - await writeFile( - join(workflowsDir, 'research-phase.md'), - makeWorkflowContent('Research the codebase thoroughly.', [ - 'Gather data from the project.\nAskUserQuestion("what?")\nAnalyze findings.', - 'Run the analysis.\n/gsd:analyze --deep\nDocument results.', - ]), - ); - await writeFile( - join(agentsDir, 'gsd-phase-researcher.md'), - makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.\nSTOP and wait for user input.\nBe thorough.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - // Interactive patterns should be stripped by sanitizePrompt() - expect(prompt).not.toContain('AskUserQuestion'); - expect(prompt).not.toContain('/gsd:'); - expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/); - - // Non-interactive content on separate lines should remain - expect(prompt).toContain('You are a researcher.'); - expect(prompt).toContain('Be thorough.'); - expect(prompt).toContain('Gather data from the project.'); - expect(prompt).toContain('Analyze findings.'); - }); - - it('buildPrompt with execute+plan sanitizes output from buildExecutorPrompt', async () => { - await writeFile( - join(agentsDir, 'gsd-executor.md'), - makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.\nSTOP and wait for user.\nExecute thoroughly.'), - ); - - const factory = makeFactory(); - const plan = makeParsedPlan({ objective: 'Build the auth system' }); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles); - - // Objective should remain (no interactive pattern on that line) - expect(prompt).toContain('Build the auth system'); - // The role's STOP directive should be stripped - expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/); - // Non-interactive role content should remain - expect(prompt).toContain('You are an executor.'); - }); - }); -}); - -describe('PHASE_WORKFLOW_MAP', () => { - it('maps all phase types to workflow filenames', () => { - for (const phase of Object.values(PhaseType)) { - expect(PHASE_WORKFLOW_MAP[phase]).toBeDefined(); - expect(PHASE_WORKFLOW_MAP[phase]).toMatch(/\.md$/); - } - }); - - it('execute phase maps to execute-plan.md (not execute-phase.md)', () => { - expect(PHASE_WORKFLOW_MAP[PhaseType.Execute]).toBe('execute-plan.md'); - }); -}); diff --git a/sdk/src/phase-runner-types.test.ts b/sdk/src/phase-runner-types.test.ts deleted file mode 100644 index 17afef5a1..000000000 --- a/sdk/src/phase-runner-types.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { GSDTools, GSDToolsError } from './gsd-tools.js'; -import { - PhaseStepType, - GSDEventType, - PhaseType, - type PhaseOpInfo, - type PhaseStepResult, - type PhaseRunnerResult, - type HumanGateCallbacks, - type PhaseRunnerOptions, - type GSDPhaseStartEvent, - type GSDPhaseStepStartEvent, - type GSDPhaseStepCompleteEvent, - type GSDPhaseCompleteEvent, -} from './types.js'; -import { mkdir, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -describe('Phase lifecycle types', () => { - // ─── PhaseStepType enum ──────────────────────────────────────────────── - - describe('PhaseStepType', () => { - it('has all expected step values', () => { - expect(PhaseStepType.Discuss).toBe('discuss'); - expect(PhaseStepType.Research).toBe('research'); - expect(PhaseStepType.Plan).toBe('plan'); - expect(PhaseStepType.Execute).toBe('execute'); - expect(PhaseStepType.Verify).toBe('verify'); - expect(PhaseStepType.Advance).toBe('advance'); - }); - - it('has exactly 7 members', () => { - const values = Object.values(PhaseStepType); - expect(values).toHaveLength(7); - }); - }); - - // ─── GSDEventType phase lifecycle values ─────────────────────────────── - - describe('GSDEventType phase lifecycle events', () => { - it('includes PhaseStart', () => { - expect(GSDEventType.PhaseStart).toBe('phase_start'); - }); - - it('includes PhaseStepStart', () => { - expect(GSDEventType.PhaseStepStart).toBe('phase_step_start'); - }); - - it('includes PhaseStepComplete', () => { - expect(GSDEventType.PhaseStepComplete).toBe('phase_step_complete'); - }); - - it('includes PhaseComplete', () => { - expect(GSDEventType.PhaseComplete).toBe('phase_complete'); - }); - }); - - // ─── PhaseOpInfo shape validation ────────────────────────────────────── - - describe('PhaseOpInfo interface', () => { - it('accepts a valid phase-op output object', () => { - const info: PhaseOpInfo = { - phase_found: true, - phase_dir: '.planning/phases/05-Skill-Scaffolding', - phase_number: '5', - phase_name: 'Skill Scaffolding', - phase_slug: 'skill-scaffolding', - padded_phase: '05', - has_research: false, - has_context: false, - has_plans: false, - has_verification: false, - plan_count: 0, - roadmap_exists: true, - planning_exists: true, - commit_docs: true, - context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md', - research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md', - }; - - expect(info.phase_found).toBe(true); - expect(info.phase_number).toBe('5'); - expect(info.plan_count).toBe(0); - expect(info.has_context).toBe(false); - }); - - it('matches the documented init phase-op JSON shape', () => { - // Simulate parsing JSON from gsd-tools.cjs - const raw = JSON.parse(JSON.stringify({ - phase_found: true, - phase_dir: '.planning/phases/03-Auth', - phase_number: '3', - phase_name: 'Auth', - phase_slug: 'auth', - padded_phase: '03', - has_research: true, - has_context: true, - has_plans: true, - has_verification: false, - plan_count: 2, - roadmap_exists: true, - planning_exists: true, - commit_docs: true, - context_path: '.planning/phases/03-Auth/CONTEXT.md', - research_path: '.planning/phases/03-Auth/RESEARCH.md', - })); - - const info = raw as PhaseOpInfo; - expect(info.phase_found).toBe(true); - expect(info.has_plans).toBe(true); - expect(info.plan_count).toBe(2); - expect(typeof info.phase_dir).toBe('string'); - expect(typeof info.padded_phase).toBe('string'); - }); - }); - - // ─── Phase result types ──────────────────────────────────────────────── - - describe('PhaseStepResult', () => { - it('can represent a successful step', () => { - const result: PhaseStepResult = { - step: PhaseStepType.Research, - success: true, - durationMs: 5000, - }; - expect(result.success).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it('can represent a failed step with error', () => { - const result: PhaseStepResult = { - step: PhaseStepType.Execute, - success: false, - durationMs: 12000, - error: 'Session timed out', - planResults: [], - }; - expect(result.success).toBe(false); - expect(result.error).toBe('Session timed out'); - }); - }); - - describe('PhaseRunnerResult', () => { - it('can represent a complete phase run', () => { - const result: PhaseRunnerResult = { - phaseNumber: '3', - phaseName: 'Auth', - steps: [ - { step: PhaseStepType.Research, success: true, durationMs: 5000 }, - { step: PhaseStepType.Plan, success: true, durationMs: 3000 }, - { step: PhaseStepType.Execute, success: true, durationMs: 60000 }, - ], - success: true, - totalCostUsd: 1.5, - totalDurationMs: 68000, - }; - expect(result.steps).toHaveLength(3); - expect(result.success).toBe(true); - }); - }); - - describe('HumanGateCallbacks', () => { - it('accepts an object with all optional callbacks', () => { - const callbacks: HumanGateCallbacks = { - onDiscussApproval: async () => 'approve', - onVerificationReview: async () => 'accept', - onBlockerDecision: async () => 'retry', - }; - expect(callbacks.onDiscussApproval).toBeDefined(); - }); - - it('accepts an empty object (all callbacks optional)', () => { - const callbacks: HumanGateCallbacks = {}; - expect(callbacks.onDiscussApproval).toBeUndefined(); - }); - }); - - describe('PhaseRunnerOptions', () => { - it('accepts full options', () => { - const options: PhaseRunnerOptions = { - callbacks: {}, - maxBudgetPerStep: 3.0, - maxTurnsPerStep: 30, - model: 'claude-sonnet-4-6', - }; - expect(options.maxBudgetPerStep).toBe(3.0); - }); - - it('accepts empty options (all fields optional)', () => { - const options: PhaseRunnerOptions = {}; - expect(options.callbacks).toBeUndefined(); - }); - }); - - // ─── Phase lifecycle event interfaces ────────────────────────────────── - - describe('Phase lifecycle event interfaces', () => { - it('GSDPhaseStartEvent has correct shape', () => { - const event: GSDPhaseStartEvent = { - type: GSDEventType.PhaseStart, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - phaseName: 'Auth', - }; - expect(event.type).toBe('phase_start'); - expect(event.phaseNumber).toBe('3'); - }); - - it('GSDPhaseStepStartEvent has correct shape', () => { - const event: GSDPhaseStepStartEvent = { - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - step: PhaseStepType.Research, - }; - expect(event.type).toBe('phase_step_start'); - expect(event.step).toBe('research'); - }); - - it('GSDPhaseStepCompleteEvent has correct shape', () => { - const event: GSDPhaseStepCompleteEvent = { - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - step: PhaseStepType.Execute, - success: true, - durationMs: 45000, - }; - expect(event.type).toBe('phase_step_complete'); - expect(event.success).toBe(true); - }); - - it('GSDPhaseStepCompleteEvent can include error', () => { - const event: GSDPhaseStepCompleteEvent = { - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - step: PhaseStepType.Verify, - success: false, - durationMs: 2000, - error: 'Verification failed', - }; - expect(event.error).toBe('Verification failed'); - }); - - it('GSDPhaseCompleteEvent has correct shape', () => { - const event: GSDPhaseCompleteEvent = { - type: GSDEventType.PhaseComplete, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - phaseName: 'Auth', - success: true, - totalCostUsd: 2.5, - totalDurationMs: 120000, - stepsCompleted: 5, - }; - expect(event.type).toBe('phase_complete'); - expect(event.stepsCompleted).toBe(5); - }); - }); -}); - -// ─── GSDTools typed methods ────────────────────────────────────────────────── - -describe('GSDTools typed methods', () => { - let tmpDir: string; - let fixtureDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-tools-phase-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fixtureDir = join(tmpDir, 'fixtures'); - await mkdir(fixtureDir, { recursive: true }); - await mkdir(join(tmpDir, '.planning'), { recursive: true }); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - async function createScript(name: string, code: string): Promise { - const scriptPath = join(fixtureDir, name); - await writeFile(scriptPath, code, { mode: 0o755 }); - return scriptPath; - } - - describe('initPhaseOp()', () => { - it('returns typed PhaseOpInfo from gsd-tools output', async () => { - const mockOutput: PhaseOpInfo = { - phase_found: true, - phase_dir: '.planning/phases/05-Skill-Scaffolding', - phase_number: '5', - phase_name: 'Skill Scaffolding', - phase_slug: 'skill-scaffolding', - padded_phase: '05', - has_research: false, - has_context: true, - has_plans: true, - has_verification: false, - plan_count: 3, - roadmap_exists: true, - planning_exists: true, - commit_docs: true, - context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md', - research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md', - }; - - const scriptPath = await createScript( - 'init-phase-op.cjs', - ` - const args = process.argv.slice(2); - // Script receives: init phase-op 5 --raw - if (args[0] === 'init' && args[1] === 'phase-op' && args[2] === '5') { - process.stdout.write(JSON.stringify(${JSON.stringify(mockOutput)})); - } else { - process.stderr.write('unexpected args: ' + args.join(' ')); - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.initPhaseOp('5'); - - expect(result.phase_found).toBe(true); - expect(result.phase_number).toBe('5'); - expect(result.phase_name).toBe('Skill Scaffolding'); - expect(result.plan_count).toBe(3); - expect(result.has_context).toBe(true); - expect(result.has_plans).toBe(true); - expect(result.context_path).toContain('CONTEXT.md'); - }); - - it('calls exec with correct args (init phase-op )', async () => { - const scriptPath = await createScript( - 'init-phase-op-args.cjs', - ` - const args = process.argv.slice(2); - process.stdout.write(JSON.stringify({ received_args: args })); - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.initPhaseOp('7') as { received_args: string[] }; - - expect(result.received_args).toContain('init'); - expect(result.received_args).toContain('phase-op'); - expect(result.received_args).toContain('7'); - // exec() no longer appends --raw (only execRaw does) - expect(result.received_args).not.toContain('--raw'); - }); - }); - - describe('configGet()', () => { - it('returns string value from gsd-tools config', async () => { - const scriptPath = await createScript( - 'config-get.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'config-get' && args[1] === 'model_profile') { - process.stdout.write(JSON.stringify('balanced')); - } else { - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.configGet('model_profile'); - - expect(result).toBe('balanced'); - }); - - it('returns null when key not found', async () => { - const scriptPath = await createScript( - 'config-get-null.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'config-get' && args[1] === 'nonexistent_key') { - process.stdout.write('null'); - } else { - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.configGet('nonexistent_key'); - - expect(result).toBeNull(); - }); - }); - - describe('stateBeginPhase()', () => { - it('calls state begin-phase with correct args', async () => { - const scriptPath = await createScript( - 'state-begin-phase.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'state' && args[1] === 'begin-phase' && args[2] === '--phase' && args[3] === '3') { - process.stdout.write('ok'); - } else { - process.stderr.write('unexpected args: ' + args.join(' ')); - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.stateBeginPhase('3'); - - expect(result).toBe('ok'); - }); - }); -}); diff --git a/sdk/src/phase-runner.test.ts b/sdk/src/phase-runner.test.ts index 398b5b9b0..38d136f01 100644 --- a/sdk/src/phase-runner.test.ts +++ b/sdk/src/phase-runner.test.ts @@ -1,3 +1,14 @@ +/** + * Phase Runner tests — consolidated from phase-runner.test.ts, + * phase-runner-types.test.ts, and phase-prompt.test.ts (issue #3740). + * + * Covers: + * • PhaseRunner state machine (mocked deps) + * • Phase lifecycle type contracts (PhaseStepType, GSDEventType, PhaseOpInfo, etc.) + * • GSDTools typed methods + * • PromptFactory / extractBlock / extractSteps / PHASE_WORKFLOW_MAP + */ + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; @@ -13,10 +24,22 @@ import type { GSDEvent, PhasePlanIndex, PlanInfo, + PhaseStepResult, + PhaseRunnerResult, + PhaseRunnerOptions, + GSDPhaseStartEvent, + GSDPhaseStepStartEvent, + GSDPhaseStepCompleteEvent, + GSDPhaseCompleteEvent, + ContextFiles, + ParsedPlan, + PlanFrontmatter, } from './types.js'; import { PhaseStepType, PhaseType, GSDEventType } from './types.js'; import type { GSDConfig } from './config.js'; import { CONFIG_DEFAULTS } from './config.js'; +import { GSDTools, GSDToolsError } from './gsd-tools.js'; +import { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js'; // ─── Mock modules ──────────────────────────────────────────────────────────── @@ -2718,3 +2741,920 @@ Use TypeScript.`, 'utf-8'); }); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Phase lifecycle type contracts +// (consolidated from sdk/src/phase-runner-types.test.ts — issue #3740) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Phase lifecycle types', () => { + // ─── PhaseStepType enum ──────────────────────────────────────────────── + + describe('PhaseStepType', () => { + it('has all expected step values', () => { + expect(PhaseStepType.Discuss).toBe('discuss'); + expect(PhaseStepType.Research).toBe('research'); + expect(PhaseStepType.Plan).toBe('plan'); + expect(PhaseStepType.Execute).toBe('execute'); + expect(PhaseStepType.Verify).toBe('verify'); + expect(PhaseStepType.Advance).toBe('advance'); + }); + + it('has exactly 7 members', () => { + const values = Object.values(PhaseStepType); + expect(values).toHaveLength(7); + }); + }); + + // ─── GSDEventType phase lifecycle values ─────────────────────────────── + + describe('GSDEventType phase lifecycle events', () => { + it('includes PhaseStart', () => { + expect(GSDEventType.PhaseStart).toBe('phase_start'); + }); + + it('includes PhaseStepStart', () => { + expect(GSDEventType.PhaseStepStart).toBe('phase_step_start'); + }); + + it('includes PhaseStepComplete', () => { + expect(GSDEventType.PhaseStepComplete).toBe('phase_step_complete'); + }); + + it('includes PhaseComplete', () => { + expect(GSDEventType.PhaseComplete).toBe('phase_complete'); + }); + }); + + // ─── PhaseOpInfo shape validation ────────────────────────────────────── + + describe('PhaseOpInfo interface', () => { + it('accepts a valid phase-op output object', () => { + const info: PhaseOpInfo = { + phase_found: true, + phase_dir: '.planning/phases/05-Skill-Scaffolding', + phase_number: '5', + phase_name: 'Skill Scaffolding', + phase_slug: 'skill-scaffolding', + padded_phase: '05', + has_research: false, + has_context: false, + has_plans: false, + has_verification: false, + plan_count: 0, + roadmap_exists: true, + planning_exists: true, + commit_docs: true, + context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md', + research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md', + }; + + expect(info.phase_found).toBe(true); + expect(info.phase_number).toBe('5'); + expect(info.plan_count).toBe(0); + expect(info.has_context).toBe(false); + }); + + it('matches the documented init phase-op JSON shape', () => { + const raw = JSON.parse(JSON.stringify({ + phase_found: true, + phase_dir: '.planning/phases/03-Auth', + phase_number: '3', + phase_name: 'Auth', + phase_slug: 'auth', + padded_phase: '03', + has_research: true, + has_context: true, + has_plans: true, + has_verification: false, + plan_count: 2, + roadmap_exists: true, + planning_exists: true, + commit_docs: true, + context_path: '.planning/phases/03-Auth/CONTEXT.md', + research_path: '.planning/phases/03-Auth/RESEARCH.md', + })); + + const info = raw as PhaseOpInfo; + expect(info.phase_found).toBe(true); + expect(info.has_plans).toBe(true); + expect(info.plan_count).toBe(2); + expect(typeof info.phase_dir).toBe('string'); + expect(typeof info.padded_phase).toBe('string'); + }); + }); + + // ─── Phase result types ──────────────────────────────────────────────── + + describe('PhaseStepResult', () => { + it('can represent a successful step', () => { + const result: PhaseStepResult = { + step: PhaseStepType.Research, + success: true, + durationMs: 5000, + }; + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('can represent a failed step with error', () => { + const result: PhaseStepResult = { + step: PhaseStepType.Execute, + success: false, + durationMs: 12000, + error: 'Session timed out', + planResults: [], + }; + expect(result.success).toBe(false); + expect(result.error).toBe('Session timed out'); + }); + }); + + describe('PhaseRunnerResult', () => { + it('can represent a complete phase run', () => { + const result: PhaseRunnerResult = { + phaseNumber: '3', + phaseName: 'Auth', + steps: [ + { step: PhaseStepType.Research, success: true, durationMs: 5000 }, + { step: PhaseStepType.Plan, success: true, durationMs: 3000 }, + { step: PhaseStepType.Execute, success: true, durationMs: 60000 }, + ], + success: true, + totalCostUsd: 1.5, + totalDurationMs: 68000, + }; + expect(result.steps).toHaveLength(3); + expect(result.success).toBe(true); + }); + }); + + describe('HumanGateCallbacks', () => { + it('accepts an object with all optional callbacks', () => { + const callbacks: HumanGateCallbacks = { + onDiscussApproval: async () => 'approve', + onVerificationReview: async () => 'accept', + onBlockerDecision: async () => 'retry', + }; + expect(callbacks.onDiscussApproval).toBeDefined(); + }); + + it('accepts an empty object (all callbacks optional)', () => { + const callbacks: HumanGateCallbacks = {}; + expect(callbacks.onDiscussApproval).toBeUndefined(); + }); + }); + + describe('PhaseRunnerOptions', () => { + it('accepts full options', () => { + const options: PhaseRunnerOptions = { + callbacks: {}, + maxBudgetPerStep: 3.0, + maxTurnsPerStep: 30, + model: 'claude-sonnet-4-6', + }; + expect(options.maxBudgetPerStep).toBe(3.0); + }); + + it('accepts empty options (all fields optional)', () => { + const options: PhaseRunnerOptions = {}; + expect(options.callbacks).toBeUndefined(); + }); + }); + + // ─── Phase lifecycle event interfaces ────────────────────────────────── + + describe('Phase lifecycle event interfaces', () => { + it('GSDPhaseStartEvent has correct shape', () => { + const event: GSDPhaseStartEvent = { + type: GSDEventType.PhaseStart, + timestamp: new Date().toISOString(), + sessionId: 'test-session', + phaseNumber: '3', + phaseName: 'Auth', + }; + expect(event.type).toBe('phase_start'); + expect(event.phaseNumber).toBe('3'); + }); + + it('GSDPhaseStepStartEvent has correct shape', () => { + const event: GSDPhaseStepStartEvent = { + type: GSDEventType.PhaseStepStart, + timestamp: new Date().toISOString(), + sessionId: 'test-session', + phaseNumber: '3', + step: PhaseStepType.Research, + }; + expect(event.type).toBe('phase_step_start'); + expect(event.step).toBe('research'); + }); + + it('GSDPhaseStepCompleteEvent has correct shape', () => { + const event: GSDPhaseStepCompleteEvent = { + type: GSDEventType.PhaseStepComplete, + timestamp: new Date().toISOString(), + sessionId: 'test-session', + phaseNumber: '3', + step: PhaseStepType.Execute, + success: true, + durationMs: 45000, + }; + expect(event.type).toBe('phase_step_complete'); + expect(event.success).toBe(true); + }); + + it('GSDPhaseStepCompleteEvent can include error', () => { + const event: GSDPhaseStepCompleteEvent = { + type: GSDEventType.PhaseStepComplete, + timestamp: new Date().toISOString(), + sessionId: 'test-session', + phaseNumber: '3', + step: PhaseStepType.Verify, + success: false, + durationMs: 2000, + error: 'Verification failed', + }; + expect(event.error).toBe('Verification failed'); + }); + + it('GSDPhaseCompleteEvent has correct shape', () => { + const event: GSDPhaseCompleteEvent = { + type: GSDEventType.PhaseComplete, + timestamp: new Date().toISOString(), + sessionId: 'test-session', + phaseNumber: '3', + phaseName: 'Auth', + success: true, + totalCostUsd: 2.5, + totalDurationMs: 120000, + stepsCompleted: 5, + }; + expect(event.type).toBe('phase_complete'); + expect(event.stepsCompleted).toBe(5); + }); + }); +}); + +// ─── GSDTools typed methods ────────────────────────────────────────────────── + +describe('GSDTools typed methods', () => { + let tmpDir: string; + let fixtureDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `gsd-tools-phase-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fixtureDir = join(tmpDir, 'fixtures'); + await mkdir(fixtureDir, { recursive: true }); + await mkdir(join(tmpDir, '.planning'), { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function createScript(name: string, code: string): Promise { + const scriptPath = join(fixtureDir, name); + await writeFile(scriptPath, code, { mode: 0o755 }); + return scriptPath; + } + + describe('initPhaseOp()', () => { + it('returns typed PhaseOpInfo from gsd-tools output', async () => { + const mockOutput: PhaseOpInfo = { + phase_found: true, + phase_dir: '.planning/phases/05-Skill-Scaffolding', + phase_number: '5', + phase_name: 'Skill Scaffolding', + phase_slug: 'skill-scaffolding', + padded_phase: '05', + has_research: false, + has_context: true, + has_plans: true, + has_verification: false, + plan_count: 3, + roadmap_exists: true, + planning_exists: true, + commit_docs: true, + context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md', + research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md', + }; + + const scriptPath = await createScript( + 'init-phase-op.cjs', + ` + const args = process.argv.slice(2); + // Script receives: init phase-op 5 --raw + if (args[0] === 'init' && args[1] === 'phase-op' && args[2] === '5') { + process.stdout.write(JSON.stringify(${JSON.stringify(mockOutput)})); + } else { + process.stderr.write('unexpected args: ' + args.join(' ')); + process.exit(1); + } + `, + ); + + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); + const result = await tools.initPhaseOp('5'); + + expect(result.phase_found).toBe(true); + expect(result.phase_number).toBe('5'); + expect(result.phase_name).toBe('Skill Scaffolding'); + expect(result.plan_count).toBe(3); + expect(result.has_context).toBe(true); + expect(result.has_plans).toBe(true); + expect(result.context_path).toContain('CONTEXT.md'); + }); + + it('calls exec with correct args (init phase-op )', async () => { + const scriptPath = await createScript( + 'init-phase-op-args.cjs', + ` + const args = process.argv.slice(2); + process.stdout.write(JSON.stringify({ received_args: args })); + `, + ); + + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); + const result = await tools.initPhaseOp('7') as { received_args: string[] }; + + expect(result.received_args).toContain('init'); + expect(result.received_args).toContain('phase-op'); + expect(result.received_args).toContain('7'); + expect(result.received_args).not.toContain('--raw'); + }); + }); + + describe('configGet()', () => { + it('returns string value from gsd-tools config', async () => { + const scriptPath = await createScript( + 'config-get.cjs', + ` + const args = process.argv.slice(2); + if (args[0] === 'config-get' && args[1] === 'model_profile') { + process.stdout.write(JSON.stringify('balanced')); + } else { + process.exit(1); + } + `, + ); + + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); + const result = await tools.configGet('model_profile'); + + expect(result).toBe('balanced'); + }); + + it('returns null when key not found', async () => { + const scriptPath = await createScript( + 'config-get-null.cjs', + ` + const args = process.argv.slice(2); + if (args[0] === 'config-get' && args[1] === 'nonexistent_key') { + process.stdout.write('null'); + } else { + process.exit(1); + } + `, + ); + + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); + const result = await tools.configGet('nonexistent_key'); + + expect(result).toBeNull(); + }); + }); + + describe('stateBeginPhase()', () => { + it('calls state begin-phase with correct args', async () => { + const scriptPath = await createScript( + 'state-begin-phase.cjs', + ` + const args = process.argv.slice(2); + if (args[0] === 'state' && args[1] === 'begin-phase' && args[2] === '--phase' && args[3] === '3') { + process.stdout.write('ok'); + } else { + process.stderr.write('unexpected args: ' + args.join(' ')); + process.exit(1); + } + `, + ); + + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); + const result = await tools.stateBeginPhase('3'); + + expect(result).toBe('ok'); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PromptFactory / extractBlock / extractSteps / PHASE_WORKFLOW_MAP +// (consolidated from sdk/src/phase-prompt.test.ts — issue #3740) +// ───────────────────────────────────────────────────────────────────────────── + +// ─── Prompt helpers ─────────────────────────────────────────────────────────── + +async function createPromptTempDir(): Promise { + return mkdtemp(join(tmpdir(), 'gsd-prompt-')); +} + +function makeWorkflowContent(purpose: string, steps: string[]): string { + const stepBlocks = steps + .map((s, i) => `\n${s}\n`) + .join('\n\n'); + return `\n${purpose}\n\n\n\n${stepBlocks}\n`; +} + +function makeAgentDef(name: string, tools: string, role: string): string { + return `---\nname: ${name}\ntools: ${tools}\n---\n\n\n${role}\n`; +} + +function makePromptParsedPlan(overrides?: Partial): ParsedPlan { + return { + frontmatter: { + phase: 'execute', + plan: 'test-plan', + type: 'feature', + wave: 1, + depends_on: [], + files_modified: [], + autonomous: true, + requirements: [], + must_haves: { truths: [], artifacts: [], key_links: [] }, + } as PlanFrontmatter, + objective: 'Test objective', + execution_context: [], + context_refs: [], + tasks: [], + raw: '', + ...overrides, + }; +} + +// ─── extractBlock tests ────────────────────────────────────────────────────── + +describe('extractBlock', () => { + it('extracts content from a simple block', () => { + const content = '\nDo the thing.\n'; + expect(extractBlock(content, 'purpose')).toBe('Do the thing.'); + }); + + it('extracts content from block with attributes', () => { + const content = '\nLoad context.\n'; + expect(extractBlock(content, 'step')).toBe('Load context.'); + }); + + it('returns empty string for missing block', () => { + const content = 'Something'; + expect(extractBlock(content, 'role')).toBe(''); + }); + + it('extracts multiline content', () => { + const content = '\nLine 1\nLine 2\nLine 3\n'; + expect(extractBlock(content, 'role')).toBe('Line 1\nLine 2\nLine 3'); + }); +}); + +describe('extractSteps', () => { + it('extracts multiple steps from process content', () => { + const process = ` +Initialize +Run tasks +Check results`; + + const steps = extractSteps(process); + expect(steps).toHaveLength(3); + expect(steps[0]).toEqual({ name: 'init', content: 'Initialize' }); + expect(steps[1]).toEqual({ name: 'execute', content: 'Run tasks' }); + expect(steps[2]).toEqual({ name: 'verify', content: 'Check results' }); + }); + + it('returns empty array for no steps', () => { + expect(extractSteps('no steps here')).toEqual([]); + }); + + it('handles steps with priority attributes', () => { + const process = '\nDo first.\n'; + const steps = extractSteps(process); + expect(steps).toHaveLength(1); + expect(steps[0].name).toBe('init'); + expect(steps[0].content).toBe('Do first.'); + }); +}); + +// ─── PromptFactory tests ───────────────────────────────────────────────────── + +describe('PromptFactory', () => { + let tempDir: string; + let workflowsDir: string; + let agentsDir: string; + + beforeEach(async () => { + tempDir = await createPromptTempDir(); + workflowsDir = join(tempDir, 'workflows'); + agentsDir = join(tempDir, 'agents'); + await mkdir(workflowsDir, { recursive: true }); + await mkdir(agentsDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function makeFactory(): PromptFactory { + return new PromptFactory({ + gsdInstallDir: tempDir, + agentsDir, + sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), + }); + } + + describe('buildPrompt', () => { + it('assembles research prompt with role + purpose + process + context', async () => { + await writeFile( + join(workflowsDir, 'research-phase.md'), + makeWorkflowContent('Research the phase.', ['Gather info', 'Analyze findings']), + ); + await writeFile( + join(agentsDir, 'gsd-phase-researcher.md'), + makeAgentDef('gsd-phase-researcher', 'Read, Grep, Bash', 'You are a researcher.'), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { + state: '# State\nproject: test', + roadmap: '# Roadmap\nphases listed', + }; + + const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); + + expect(prompt).toContain('## Agent Instructions'); + expect(prompt).toContain('You are a researcher.'); + expect(prompt).toContain('## Purpose'); + expect(prompt).toContain('Research the phase.'); + expect(prompt).toContain('## Process'); + expect(prompt).toContain('Gather info'); + expect(prompt).toContain('## Context'); + expect(prompt).toContain('# State'); + expect(prompt).toContain('# Roadmap'); + + const agentIdx = prompt.indexOf('## Agent Instructions'); + const contextIdx = prompt.indexOf('## Context'); + expect(agentIdx).toBeLessThan(contextIdx); + }); + + it('assembles plan prompt with all context files', async () => { + await writeFile( + join(workflowsDir, 'plan-phase.md'), + makeWorkflowContent('Plan the implementation.', ['Break down tasks']), + ); + await writeFile( + join(agentsDir, 'gsd-planner.md'), + makeAgentDef('gsd-planner', 'Read, Write, Bash', 'You are a planner.'), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { + state: '# State', + roadmap: '# Roadmap', + context: '# Context', + research: '# Research', + requirements: '# Requirements', + }; + + const prompt = await factory.buildPrompt(PhaseType.Plan, null, contextFiles); + + expect(prompt).toContain('You are a planner.'); + expect(prompt).toContain('Plan the implementation.'); + expect(prompt).toContain('# State'); + expect(prompt).toContain('# Research'); + expect(prompt).toContain('# Requirements'); + }); + + it('delegates execute phase with plan to buildExecutorPrompt', async () => { + await writeFile( + join(agentsDir, 'gsd-executor.md'), + makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'), + ); + + const factory = makeFactory(); + const plan = makePromptParsedPlan({ objective: 'Build the auth system' }); + const contextFiles: ContextFiles = { state: '# State' }; + + const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles); + + expect(prompt).toContain('## Objective'); + expect(prompt).toContain('Build the auth system'); + expect(prompt).toContain('## Role'); + expect(prompt).toContain('You are an executor.'); + }); + + it('handles execute phase without plan (non-delegation path)', async () => { + await writeFile( + join(workflowsDir, 'execute-plan.md'), + makeWorkflowContent('Execute the plan.', ['Run tasks']), + ); + await writeFile( + join(agentsDir, 'gsd-executor.md'), + makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { state: '# State' }; + + const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles); + + expect(prompt).toContain('## Agent Instructions'); + expect(prompt).toContain('You are an executor.'); + expect(prompt).toContain('## Purpose'); + expect(prompt).toContain('Execute the plan.'); + }); + + it('assembles verify prompt with phase instructions', async () => { + await writeFile( + join(workflowsDir, 'verify-phase.md'), + makeWorkflowContent('Verify phase goals.', ['Check artifacts', 'Run tests']), + ); + await writeFile( + join(agentsDir, 'gsd-verifier.md'), + makeAgentDef('gsd-verifier', 'Read, Bash, Grep', 'You are a verifier.'), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { + state: '# State', + roadmap: '# Roadmap', + requirements: '# Requirements', + }; + + const prompt = await factory.buildPrompt(PhaseType.Verify, null, contextFiles); + + expect(prompt).toContain('You are a verifier.'); + expect(prompt).toContain('Verify phase goals.'); + }); + + it('assembles discuss prompt without agent role (no dedicated agent)', async () => { + await writeFile( + join(workflowsDir, 'discuss-phase.md'), + makeWorkflowContent('Discuss implementation decisions.', ['Identify areas']), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { state: '# State' }; + + const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles); + + expect(prompt).not.toContain('## Agent Instructions'); + expect(prompt).toContain('## Purpose'); + expect(prompt).toContain('Discuss implementation decisions.'); + }); + + it('handles missing workflow file gracefully', async () => { + await writeFile( + join(agentsDir, 'gsd-phase-researcher.md'), + makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.'), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { state: '# State' }; + + const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); + + expect(prompt).toContain('## Agent Instructions'); + expect(prompt).toContain('## Context'); + expect(prompt).not.toContain('## Purpose'); + }); + + it('handles missing agent def gracefully', async () => { + await writeFile( + join(workflowsDir, 'research-phase.md'), + makeWorkflowContent('Research the phase.', ['Gather info']), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { state: '# State' }; + + const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); + + expect(prompt).not.toContain('## Agent Instructions'); + expect(prompt).toContain('## Purpose'); + expect(prompt).toContain('Research the phase.'); + }); + + it('omits empty context section when no files provided', async () => { + await writeFile( + join(workflowsDir, 'discuss-phase.md'), + makeWorkflowContent('Discuss things.', ['Talk']), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = {}; + + const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles); + + expect(prompt).not.toContain('## Context'); + }); + }); + + describe('loadWorkflowFile', () => { + it('loads existing workflow file', async () => { + await writeFile( + join(workflowsDir, 'research-phase.md'), + 'workflow content', + ); + + const factory = makeFactory(); + const content = await factory.loadWorkflowFile(PhaseType.Research); + expect(content).toBe('workflow content'); + }); + + it('returns undefined for missing workflow file', async () => { + const factory = makeFactory(); + const content = await factory.loadWorkflowFile(PhaseType.Research); + expect(content).toBeUndefined(); + }); + }); + + describe('loadAgentDef', () => { + it('loads agent def from agents dir', async () => { + await writeFile( + join(agentsDir, 'gsd-executor.md'), + 'agent content', + ); + + const factory = makeFactory(); + const content = await factory.loadAgentDef(PhaseType.Execute); + expect(content).toBe('agent content'); + }); + + it('returns undefined for phases with no agent (discuss)', async () => { + const factory = makeFactory(); + const content = await factory.loadAgentDef(PhaseType.Discuss); + expect(content).toBeUndefined(); + }); + + it('falls back to project agents dir', async () => { + const projectAgentsDir = join(tempDir, 'project-agents'); + await mkdir(projectAgentsDir, { recursive: true }); + await writeFile( + join(projectAgentsDir, 'gsd-executor.md'), + 'project agent content', + ); + + const factory = new PromptFactory({ + gsdInstallDir: tempDir, + agentsDir, + projectAgentsDir, + sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), + }); + + const content = await factory.loadAgentDef(PhaseType.Execute); + expect(content).toBe('project agent content'); + }); + + it('prefers user agents dir over project agents dir', async () => { + const projectAgentsDir = join(tempDir, 'project-agents'); + await mkdir(projectAgentsDir, { recursive: true }); + await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); + await writeFile(join(projectAgentsDir, 'gsd-executor.md'), 'project agent'); + + const factory = new PromptFactory({ + gsdInstallDir: tempDir, + agentsDir, + projectAgentsDir, + sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), + }); + + const content = await factory.loadAgentDef(PhaseType.Execute); + expect(content).toBe('user agent'); + }); + }); + + // ─── Headless prompt loading ───────────────────────────────────────────── + + describe('headless prompt loading', () => { + it('loadWorkflowFile prefers installed GSD over sdkPromptsDir', async () => { + const sdkDir = join(tempDir, 'sdk-prompts'); + await mkdir(join(sdkDir, 'workflows'), { recursive: true }); + + await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original'); + await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK bundled version'); + + const factory = new PromptFactory({ + gsdInstallDir: tempDir, + agentsDir, + sdkPromptsDir: sdkDir, + }); + + const content = await factory.loadWorkflowFile(PhaseType.Research); + expect(content).toBe('GSD-1 original'); + }); + + it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => { + const sdkDir = join(tempDir, 'sdk-prompts'); + await mkdir(join(sdkDir, 'workflows'), { recursive: true }); + + await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original'); + + const factory = new PromptFactory({ + gsdInstallDir: tempDir, + agentsDir, + sdkPromptsDir: sdkDir, + }); + + const content = await factory.loadWorkflowFile(PhaseType.Research); + expect(content).toBe('GSD-1 original'); + }); + + it('loadAgentDef prefers installed agents over sdkPromptsDir', async () => { + const sdkDir = join(tempDir, 'sdk-prompts'); + await mkdir(join(sdkDir, 'agents'), { recursive: true }); + + await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); + await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK bundled agent'); + + const factory = new PromptFactory({ + gsdInstallDir: tempDir, + agentsDir, + sdkPromptsDir: sdkDir, + }); + + const content = await factory.loadAgentDef(PhaseType.Execute); + expect(content).toBe('user agent'); + }); + + it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => { + const sdkDir = join(tempDir, 'sdk-prompts'); + await mkdir(join(sdkDir, 'agents'), { recursive: true }); + + await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); + + const factory = new PromptFactory({ + gsdInstallDir: tempDir, + agentsDir, + sdkPromptsDir: sdkDir, + }); + + const content = await factory.loadAgentDef(PhaseType.Execute); + expect(content).toBe('user agent'); + }); + + it('buildPrompt sanitizes interactive patterns from output', async () => { + await writeFile( + join(workflowsDir, 'research-phase.md'), + makeWorkflowContent('Research the codebase thoroughly.', [ + 'Gather data from the project.\nAskUserQuestion("what?")\nAnalyze findings.', + 'Run the analysis.\n/gsd:analyze --deep\nDocument results.', + ]), + ); + await writeFile( + join(agentsDir, 'gsd-phase-researcher.md'), + makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.\nSTOP and wait for user input.\nBe thorough.'), + ); + + const factory = makeFactory(); + const contextFiles: ContextFiles = { state: '# State' }; + + const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); + + expect(prompt).not.toContain('AskUserQuestion'); + expect(prompt).not.toContain('/gsd:'); + expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/); + + expect(prompt).toContain('You are a researcher.'); + expect(prompt).toContain('Be thorough.'); + expect(prompt).toContain('Gather data from the project.'); + expect(prompt).toContain('Analyze findings.'); + }); + + it('buildPrompt with execute+plan sanitizes output from buildExecutorPrompt', async () => { + await writeFile( + join(agentsDir, 'gsd-executor.md'), + makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.\nSTOP and wait for user.\nExecute thoroughly.'), + ); + + const factory = makeFactory(); + const plan = makePromptParsedPlan({ objective: 'Build the auth system' }); + const contextFiles: ContextFiles = { state: '# State' }; + + const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles); + + expect(prompt).toContain('Build the auth system'); + expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/); + expect(prompt).toContain('You are an executor.'); + }); + }); +}); + +describe('PHASE_WORKFLOW_MAP', () => { + it('maps all phase types to workflow filenames', () => { + for (const phase of Object.values(PhaseType)) { + expect(PHASE_WORKFLOW_MAP[phase]).toBeDefined(); + expect(PHASE_WORKFLOW_MAP[phase]).toMatch(/\.md$/); + } + }); + + it('execute phase maps to execute-plan.md (not execute-phase.md)', () => { + expect(PHASE_WORKFLOW_MAP[PhaseType.Execute]).toBe('execute-plan.md'); + }); +}); diff --git a/sdk/src/query/phase-roadmap-mutation.ts b/sdk/src/query/phase-roadmap-mutation.ts index 2bb53e62e..61a66f219 100644 --- a/sdk/src/query/phase-roadmap-mutation.ts +++ b/sdk/src/query/phase-roadmap-mutation.ts @@ -7,19 +7,17 @@ import { acquireStateLock, releaseStateLock } from './state-mutation.js'; * * Port of replaceInCurrentMilestone from core.cjs lines 1013-1022. * - * Semantics (byte-for-byte CJS parity): + * Semantics: * • No `
` in the content → plain `content.replace(pattern, replacement)`. * • Otherwise → split at the last `
` and replace only in the * content AFTER it. - * - * INTENTIONALLY DOES NOT fall back to "search the last
block when - * the after-slice didn't match." That fallback existed in an earlier SDK - * port and would silently corrupt shipped-milestone content when the current - * milestone is itself wrapped in `
...
` and there's - * nothing after the close tag. CJS callers handle the "milestone inside - *
" case by passing the unscoped `content.replace(...)` directly - * (see phase.cjs:1080 for plan-count update). Keep this function in - * lockstep with core.cjs — deviations are how bug-2005 slipped in. + * • If the after-slice produces no replacement (pattern not found there), + * fall back to replacing inside the last `
...
` block. + * This handles the case where the active milestone is itself wrapped in + * a `
` block (e.g. collapsed by the user or during a milestone + * transition). Earlier shipped-milestone blocks are left untouched because + * only the last `
` block is targeted by the fallback. + * (Fixes #2641.) */ export function replaceInCurrentMilestone( content: string, @@ -33,7 +31,23 @@ export function replaceInCurrentMilestone( const offset = lastDetailsClose + '
'.length; const before = content.slice(0, offset); const after = content.slice(offset); - return before + after.replace(pattern, replacement); + const afterReplaced = after.replace(pattern, replacement); + if (afterReplaced !== after) { + // Pattern matched in the after-slice (normal case: active milestone is + // outside/after the last
). + return before + afterReplaced; + } + // Pattern did not match after the last
. Fall back to replacing + // inside the last
block (active milestone is wrapped in
). + const lastDetailsOpen = content.lastIndexOf('
'); + if (lastDetailsOpen === -1 || lastDetailsOpen >= lastDetailsClose) { + // Malformed or no proper block — return unchanged. + return content; + } + const blockBefore = content.slice(0, lastDetailsOpen); + const blockInner = content.slice(lastDetailsOpen, offset); + const blockAfter = content.slice(offset); + return blockBefore + blockInner.replace(pattern, replacement) + blockAfter; } /** diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts index d6e23839d..2d1993bd3 100644 --- a/sdk/src/query/phase.ts +++ b/sdk/src/query/phase.ts @@ -347,9 +347,18 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) const phaseFiles = await readdir(phaseDir); const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort(); const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - // #2893 parity — same diagnostic format as find-phase / phases-list. Use the - // centralised helper so the message shape never drifts between read sites. - const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles); + // #3430 — identify non-canonical plan-shaped files that were skipped so + // callers get an explicit warning rather than a silent plan_count: 0. + // Uses a concise format ("Ignored noncanonical plan files: ") + // for the warnings[] array; find-phase / phases-list use the verbose + // describeNonCanonicalPlans format which includes remediation guidance. + const matchedPlanSet = new Set(planFiles); + const nonCanonicalPlanFiles = phaseFiles.filter( + f => looksLikePlanFile(f) && !matchedPlanSet.has(f), + ); + const planNamingWarning = nonCanonicalPlanFiles.length > 0 + ? `Ignored noncanonical plan files: ${nonCanonicalPlanFiles.join(', ')}` + : null; // Build set of plan IDs with summaries — match the planId derivation logic const completedPlanIds = new Set( @@ -589,6 +598,12 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) waves[waveKey].push(raw.id); } + // #3430 — non-canonical plan filename warning flows through the same + // `warnings` array as other diagnostics (unresolved deps, wave mismatches). + if (planNamingWarning) { + warnings.push(planNamingWarning); + } + const result: Record = { phase: normalized, plans, @@ -596,12 +611,6 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) incomplete, has_checkpoints: hasCheckpoints, }; - // #2893 — non-canonical plan filename warning is a singular `warning` field; - // see describeNonCanonicalPlans above. Other diagnostics (unresolved deps, - // wave-declaration mismatches) flow through the existing `warnings` array. - if (planNamingWarning) { - result['warning'] = planNamingWarning; - } if (warnings.length > 0) { result['warnings'] = warnings; } diff --git a/tests/bug-1962-phase-suffix-case.test.cjs b/tests/bug-1962-phase-suffix-case.test.cjs deleted file mode 100644 index 1c00d0f03..000000000 --- a/tests/bug-1962-phase-suffix-case.test.cjs +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Regression tests for bug #1962 - * - * normalizePhaseName must preserve the original case of letter suffixes. - * Uppercasing "16c" to "16C" causes directory/roadmap mismatches on - * case-sensitive filesystems — init progress can't match the directory - * back to the roadmap phase entry. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { normalizePhaseName } = require('../get-shit-done/bin/lib/core.cjs'); - -describe('bug #1962: normalizePhaseName preserves letter suffix case', () => { - test('lowercase suffix preserved: 16c → 16c', () => { - assert.equal(normalizePhaseName('16c'), '16c'); - }); - - test('uppercase suffix preserved: 16C → 16C', () => { - assert.equal(normalizePhaseName('16C'), '16C'); - }); - - test('single digit padded with lowercase suffix: 1a → 01a', () => { - assert.equal(normalizePhaseName('1a'), '01a'); - }); - - test('single digit padded with uppercase suffix: 1A → 01A', () => { - assert.equal(normalizePhaseName('1A'), '01A'); - }); - - test('no suffix unchanged: 16 → 16', () => { - assert.equal(normalizePhaseName('16'), '16'); - }); - - test('decimal suffix preserved: 16.1 → 16.1', () => { - assert.equal(normalizePhaseName('16.1'), '16.1'); - }); - - test('letter + decimal preserved: 16c.2 → 16c.2', () => { - assert.equal(normalizePhaseName('16c.2'), '16c.2'); - }); - - test('project code prefix stripped, suffix case preserved: CK-01a → 01a', () => { - assert.equal(normalizePhaseName('CK-01a'), '01a'); - }); -}); diff --git a/tests/bug-1998-phase-complete-checkbox.test.cjs b/tests/bug-1998-phase-complete-checkbox.test.cjs deleted file mode 100644 index 61aeafb31..000000000 --- a/tests/bug-1998-phase-complete-checkbox.test.cjs +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Regression tests for bug #1998 - * - * phase complete must update the top-level overview bullet checkbox - * (- [ ] Phase N: → - [x] Phase N:) in addition to the Progress table row. - * - * Root cause: the checkbox update used replaceInCurrentMilestone() which - * scopes to content after
, missing the current milestone's - * overview bullets that appear before any
blocks. - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { execFileSync } = require('node:child_process'); - -const gsdTools = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); - -describe('bug #1998: phase complete updates overview checkbox', () => { - let tmpDir; - let planningDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1998-')); - planningDir = path.join(tmpDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - - // Minimal config - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ project_code: 'TEST' }) - ); - - // Minimal STATE.md - fs.writeFileSync( - path.join(planningDir, 'STATE.md'), - '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' - ); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('checkbox updated when no archived milestones exist', () => { - const phasesDir = path.join(planningDir, 'phases', '01-foundation'); - fs.mkdirSync(phasesDir, { recursive: true }); - fs.writeFileSync( - path.join(phasesDir, '01-1-SUMMARY.md'), - '---\nstatus: complete\n---\n# Summary\nDone.' - ); - fs.writeFileSync( - path.join(phasesDir, '01-1-PLAN.md'), - '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' - ); - - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - fs.writeFileSync(roadmapPath, [ - '# Roadmap', - '', - '## Phases', - '', - '- [ ] **Phase 1: Foundation** - core setup', - '- [ ] **Phase 2: Features** - add features', - '', - '## Progress', - '', - '| Phase | Plans | Status | Completed |', - '|-------|-------|--------|-----------|', - '| 1. Foundation | 0/1 | Pending | - |', - '| 2. Features | 0/1 | Pending | - |', - ].join('\n')); - - try { - execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); - } catch { - // Command may exit non-zero if STATE.md update fails, but ROADMAP.md update happens first - } - - const result = fs.readFileSync(roadmapPath, 'utf-8'); - assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked'); - assert.match(result, /- \[ \] \*\*Phase 2: Features\*\*/, 'phase 2 checkbox should remain unchecked'); - }); - - test('checkbox updated when archived milestones exist in
', () => { - const phasesDir = path.join(planningDir, 'phases', '01-setup'); - fs.mkdirSync(phasesDir, { recursive: true }); - fs.writeFileSync( - path.join(phasesDir, '01-1-SUMMARY.md'), - '---\nstatus: complete\n---\n# Summary\nDone.' - ); - fs.writeFileSync( - path.join(phasesDir, '01-1-PLAN.md'), - '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' - ); - - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - fs.writeFileSync(roadmapPath, [ - '# Roadmap v2.0', - '', - '## Phases', - '', - '- [ ] **Phase 1: Setup** - initial setup', - '- [ ] **Phase 2: Build** - build features', - '', - '## Progress', - '', - '| Phase | Plans | Status | Completed |', - '|-------|-------|--------|-----------|', - '| 1. Setup | 0/1 | Pending | - |', - '| 2. Build | 0/1 | Pending | - |', - '', - '
', - 'v1.0 (Archived)', - '', - '## v1.0 Phases', - '- [x] **Phase 1: Init** - initialization', - '- [x] **Phase 2: Deploy** - deployment', - '', - '
', - ].join('\n')); - - try { - execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); - } catch { - // May exit non-zero - } - - const result = fs.readFileSync(roadmapPath, 'utf-8'); - assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked'); - assert.match(result, /- \[ \] \*\*Phase 2: Build\*\*/, 'phase 2 checkbox should remain unchecked'); - }); -}); diff --git a/tests/bug-2005-phase-complete-details.test.cjs b/tests/bug-2005-phase-complete-details.test.cjs deleted file mode 100644 index 7a98d0e2b..000000000 --- a/tests/bug-2005-phase-complete-details.test.cjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Regression tests for bug #2005 - * - * When the in-progress milestone section is wrapped in a
block - * (the standard /gsd-new-project layout), phase complete silently skips: - * 1. The plan count update (**Plans:** N/M → X/M plans complete) - * 2. Mis-reports is_last_phase and next_phase - * - * Root cause: replaceInCurrentMilestone() uses the last
as the - * boundary, so when the current milestone is itself inside
, the - * replacement target is in the empty space AFTER the current milestone's - * closing
, and the regex never matches anything inside the block. - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { execFileSync } = require('node:child_process'); - -const gsdTools = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); - -describe('bug #2005: phase complete updates plan count when milestone is inside
', () => { - let tmpDir; - let planningDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2005-')); - planningDir = path.join(tmpDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ project_code: 'TEST' }) - ); - - fs.writeFileSync( - path.join(planningDir, 'STATE.md'), - '---\ncurrent_phase: 1\nstatus: executing\nmilestone: v2.0\n---\n# State\n' - ); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('plan count is updated when current milestone is wrapped in
', () => { - // This is the standard /gsd-new-project layout: every milestone in
- const phasesDir = path.join(planningDir, 'phases', '01-setup'); - fs.mkdirSync(phasesDir, { recursive: true }); - fs.writeFileSync( - path.join(phasesDir, '01-1-SUMMARY.md'), - '---\nstatus: complete\n---\n# Summary\nDone.\n' - ); - fs.writeFileSync( - path.join(phasesDir, '01-1-PLAN.md'), - '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' - ); - - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - // Current milestone (v2.0) is wrapped in
- fs.writeFileSync(roadmapPath, [ - '# Roadmap', - '', - '
', - 'v1.0 (shipped)', - '', - '## v1.0 Phases', - '- [x] **Phase 0: Bootstrap** - shipped', - '', - '
', - '', - '
', - 'v2.0 (in progress)', - '', - '## v2.0 Phases', - '', - '- [ ] **Phase 1: Setup** - initial setup', - '- [ ] **Phase 2: Build** - build features', - '', - '## Progress', - '', - '| Phase | Plans | Status | Completed |', - '|-------|-------|--------|-----------|', - '| 1. Setup | 0/1 | Pending | - |', - '| 2. Build | 0/1 | Pending | - |', - '', - '### Phase 1: Setup', - '', - '**Plans:** 0/1 plans complete', - '', - '### Phase 2: Build', - '', - '**Plans:** 0/1 plans complete', - '', - '
', - ].join('\n')); - - try { - execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); - } catch { - // May exit non-zero if STATE.md update fails, but ROADMAP.md update is the target - } - - const result = fs.readFileSync(roadmapPath, 'utf-8'); - - // Plan count must be updated inside the
block - assert.match( - result, - /\*\*Plans:\*\*\s*1\/1 plans complete/, - 'plan count in Phase 1 section must be updated to 1/1 plans complete' - ); - - // Phase 1 checkbox must be checked - assert.match( - result, - /- \[x\] \*\*Phase 1: Setup\*\*/, - 'Phase 1 checkbox must be checked after completion' - ); - - // Phase 2 must be untouched - assert.match( - result, - /- \[ \] \*\*Phase 2: Build\*\*/, - 'Phase 2 checkbox must remain unchecked' - ); - }); - - test('phase complete with all milestones in
does not corrupt phase 2 plan count', () => { - const phasesDir = path.join(planningDir, 'phases', '01-setup'); - fs.mkdirSync(phasesDir, { recursive: true }); - fs.writeFileSync( - path.join(phasesDir, '01-1-SUMMARY.md'), - '---\nstatus: complete\n---\n# Summary\nDone.\n' - ); - fs.writeFileSync( - path.join(phasesDir, '01-1-PLAN.md'), - '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' - ); - - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - fs.writeFileSync(roadmapPath, [ - '# Roadmap', - '', - '
', - 'v2.0 (in progress)', - '', - '## v2.0 Phases', - '', - '- [ ] **Phase 1: Setup** - initial setup', - '- [ ] **Phase 2: Build** - build features', - '', - '### Phase 1: Setup', - '', - '**Plans:** 0/1 plans complete', - '', - '### Phase 2: Build', - '', - '**Plans:** 0/2 plans complete', - '', - '
', - ].join('\n')); - - try { - execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); - } catch {} - - const result = fs.readFileSync(roadmapPath, 'utf-8'); - - // Phase 2's plan count must NOT be touched - assert.match( - result, - /Phase 2: Build[\s\S]*?\*\*Plans:\*\*\s*0\/2 plans complete/, - 'Phase 2 plan count must remain 0/2 (untouched)' - ); - }); -}); diff --git a/tests/bug-2526-phase-complete-req-discovery.test.cjs b/tests/bug-2526-phase-complete-req-discovery.test.cjs deleted file mode 100644 index 39c2a8452..000000000 --- a/tests/bug-2526-phase-complete-req-discovery.test.cjs +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Regression tests for bug #2526 - * - * phase complete must warn about REQ-IDs that appear in the REQUIREMENTS.md - * body but are missing from the Traceability table. - * - * Root cause: cmdPhaseComplete() only flips status for REQ-IDs already in - * the Traceability table (from the roadmap **Requirements:** line). REQ-IDs - * added to the REQUIREMENTS.md body after roadmap creation are never - * discovered or reflected in the table. - * - * Fix (Option A — warning only): scan the REQUIREMENTS.md body for all - * REQ-IDs, check which are absent from the Traceability table, and emit - * a warning listing the missing IDs. - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { execFileSync } = require('node:child_process'); - -const gsdTools = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); - -describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => { - let tmpDir; - let planningDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-')); - planningDir = path.join(tmpDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - - // Minimal config - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ project_code: '' }) - ); - - // Minimal STATE.md - fs.writeFileSync( - path.join(planningDir, 'STATE.md'), - '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' - ); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('emits warning for REQ-IDs in body but missing from Traceability table', () => { - // Set up phase directory with a plan and summary - const phasesDir = path.join(planningDir, 'phases', '01-foundation'); - fs.mkdirSync(phasesDir, { recursive: true }); - fs.writeFileSync( - path.join(phasesDir, '01-1-PLAN.md'), - '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' - ); - fs.writeFileSync( - path.join(phasesDir, '01-1-SUMMARY.md'), - '---\nstatus: complete\n---\n# Summary\nDone.' - ); - - // ROADMAP.md — phase 1 lists only REQ-001 in its Requirements line - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - fs.writeFileSync(roadmapPath, [ - '# Roadmap', - '', - '### Phase 1: Foundation', - '', - '**Goal:** Build core', - '**Requirements:** REQ-001', - '**Plans:** 1 plans', - '', - 'Plans:', - '- [x] 01-1-PLAN.md', - '', - '| Phase | Plans | Status | Completed |', - '|-------|-------|--------|-----------|', - '| 1. Foundation | 0/1 | Pending | - |', - ].join('\n')); - - // REQUIREMENTS.md — body has REQ-001 (in table) and REQ-002, REQ-003 (missing from table) - const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); - fs.writeFileSync(reqPath, [ - '# Requirements', - '', - '## Functional Requirements', - '', - '- [x] **REQ-001**: Core data model', - '- [ ] **REQ-002**: User authentication', - '- [ ] **REQ-003**: API endpoints', - '', - '## Traceability', - '', - '| REQ-ID | Phase | Status |', - '|--------|-------|--------|', - '| REQ-001 | 1 | Pending |', - ].join('\n')); - - let stdout = ''; - let stderr = ''; - try { - const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { - cwd: tmpDir, - timeout: 10000, - encoding: 'utf-8', - }); - stdout = result; - } catch (err) { - stdout = err.stdout || ''; - stderr = err.stderr || ''; - throw err; - } - - const combined = stdout + stderr; - assert.match( - combined, - /REQ-002/, - 'output should mention REQ-002 as missing from Traceability table' - ); - assert.match( - combined, - /REQ-003/, - 'output should mention REQ-003 as missing from Traceability table' - ); - }); - - test('no warning when all body REQ-IDs are present in Traceability table', () => { - // Set up phase directory - const phasesDir = path.join(planningDir, 'phases', '01-foundation'); - fs.mkdirSync(phasesDir, { recursive: true }); - fs.writeFileSync( - path.join(phasesDir, '01-1-PLAN.md'), - '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' - ); - fs.writeFileSync( - path.join(phasesDir, '01-1-SUMMARY.md'), - '---\nstatus: complete\n---\n# Summary\nDone.' - ); - - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - fs.writeFileSync(roadmapPath, [ - '# Roadmap', - '', - '### Phase 1: Foundation', - '', - '**Goal:** Build core', - '**Requirements:** REQ-001, REQ-002', - '**Plans:** 1 plans', - '', - 'Plans:', - '- [x] 01-1-PLAN.md', - '', - '| Phase | Plans | Status | Completed |', - '|-------|-------|--------|-----------|', - '| 1. Foundation | 0/1 | Pending | - |', - ].join('\n')); - - // All body REQ-IDs are present in the Traceability table - const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); - fs.writeFileSync(reqPath, [ - '# Requirements', - '', - '## Functional Requirements', - '', - '- [x] **REQ-001**: Core data model', - '- [x] **REQ-002**: User authentication', - '', - '## Traceability', - '', - '| REQ-ID | Phase | Status |', - '|--------|-------|--------|', - '| REQ-001 | 1 | Pending |', - '| REQ-002 | 1 | Pending |', - ].join('\n')); - - let stdout = ''; - let stderr = ''; - try { - const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { - cwd: tmpDir, - timeout: 10000, - encoding: 'utf-8', - }); - stdout = result; - } catch (err) { - stdout = err.stdout || ''; - stderr = err.stderr || ''; - throw err; - } - - const combined = stdout + stderr; - assert.doesNotMatch( - combined, - /unregistered|missing.*traceability|not in.*traceability/i, - 'no warning should appear when all REQ-IDs are in the table' - ); - }); - - test('warning includes all missing REQ-IDs, not just the first', () => { - const phasesDir = path.join(planningDir, 'phases', '01-foundation'); - fs.mkdirSync(phasesDir, { recursive: true }); - fs.writeFileSync( - path.join(phasesDir, '01-1-PLAN.md'), - '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' - ); - fs.writeFileSync( - path.join(phasesDir, '01-1-SUMMARY.md'), - '---\nstatus: complete\n---\n# Summary\nDone.' - ); - - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - fs.writeFileSync(roadmapPath, [ - '# Roadmap', - '', - '### Phase 1: Foundation', - '', - '**Goal:** Build core', - '**Requirements:** REQ-001', - '**Plans:** 1 plans', - '', - 'Plans:', - '- [x] 01-1-PLAN.md', - '', - '| Phase | Plans | Status | Completed |', - '|-------|-------|--------|-----------|', - '| 1. Foundation | 0/1 | Pending | - |', - ].join('\n')); - - // Body has 4 REQ-IDs; table only has 1 - const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); - fs.writeFileSync(reqPath, [ - '# Requirements', - '', - '- [x] **REQ-001**: Core data model', - '- [ ] **REQ-002**: User auth', - '- [ ] **REQ-003**: API', - '- [ ] **REQ-004**: Reports', - '', - '## Traceability', - '', - '| REQ-ID | Phase | Status |', - '|--------|-------|--------|', - '| REQ-001 | 1 | Pending |', - ].join('\n')); - - let stdout = ''; - let stderr = ''; - try { - const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { - cwd: tmpDir, - timeout: 10000, - encoding: 'utf-8', - }); - stdout = result; - } catch (err) { - stdout = err.stdout || ''; - stderr = err.stderr || ''; - throw err; - } - - const combined = stdout + stderr; - assert.match(combined, /REQ-002/, 'should warn about REQ-002'); - assert.match(combined, /REQ-003/, 'should warn about REQ-003'); - assert.match(combined, /REQ-004/, 'should warn about REQ-004'); - }); -}); diff --git a/tests/bug-3287-phase-dir-prefix-parity.test.cjs b/tests/bug-3287-phase-dir-prefix-parity.test.cjs deleted file mode 100644 index ffe52fe61..000000000 --- a/tests/bug-3287-phase-dir-prefix-parity.test.cjs +++ /dev/null @@ -1,198 +0,0 @@ -'use strict'; -/** - * Regression test for #3287 — phase-dir prefix parity across creation paths. - * - * Projects with `project_code` set in `.planning/config.json` must get the - * same `--` directory shape from ALL phase-creation paths, - * not just from `phase.add` / `phase.insert`. - * - * Three tests: - * A — sanity: `phase.add` emits the prefixed dir (already works). - * B — init phase-op exposes `expected_phase_dir` with the prefix when - * the directory does not yet exist (first-touch path for /gsd-discuss-phase). - * C — init plan-phase exposes `expected_phase_dir` with the prefix when - * the directory does not yet exist (first-touch path for /gsd-plan-phase). - * - * Tests B and C are RED until the fix lands. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -// ─── shared fixture ────────────────────────────────────────────────────────── - -function makeXRProject(tmpDir) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ project_code: 'XR' }), - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap v1.0', - '', - '### Phase 1: Foundation', - '**Goal:** Setup project', - '**Plans:** 0 plans', - '', - '---', - '', - ].join('\n'), - ); -} - -// ─── Test A — sanity: phase.add honours project_code ───────────────────────── - -describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('phase.add creates XR-02- when project_code is XR', () => { - makeXRProject(tmpDir); - - const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `phase.add failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase_number, 2, 'phase number should be 2'); - - const phasesDir = path.join(tmpDir, '.planning', 'phases'); - const dirs = fs.readdirSync(phasesDir); - const prefixedDirs = dirs.filter(d => d.startsWith('XR-')); - assert.ok( - prefixedDirs.length > 0, - `Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`, - ); - assert.ok( - dirs.some(d => d === 'XR-02-auth-service'), - `Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`, - ); - }); -}); - -// ─── Test B — init phase-op exposes expected_phase_dir with prefix ──────────── - -describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { - makeXRProject(tmpDir); - - // Phase 1 is in the roadmap but has no directory yet — the first-touch path - const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `init phase-op failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); - assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); - - // The fix: expected_phase_dir must carry the project_code prefix - assert.ok( - typeof output.expected_phase_dir === 'string', - `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, - ); - assert.ok( - output.expected_phase_dir.includes('XR-'), - `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, - ); - assert.ok( - output.expected_phase_dir.includes('foundation'), - `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, - ); - }); - - test('expected_phase_dir is null when no project_code is set', () => { - // No project_code — expected_phase_dir should still be present but without prefix - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({}), - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', - ); - - const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `init phase-op failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase_dir, null); - assert.ok( - typeof output.expected_phase_dir === 'string', - `expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`, - ); - // Without project_code, should have no prefix — just NN-slug - assert.ok( - !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), - `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, - ); - }); -}); - -// ─── Test C — init plan-phase exposes expected_phase_dir with prefix ────────── - -describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { - makeXRProject(tmpDir); - - // Phase 1 is in the roadmap but has no directory yet — the first-touch path - const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `init plan-phase failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); - assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); - - // The fix: expected_phase_dir must carry the project_code prefix - assert.ok( - typeof output.expected_phase_dir === 'string', - `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, - ); - assert.ok( - output.expected_phase_dir.includes('XR-'), - `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, - ); - assert.ok( - output.expected_phase_dir.includes('foundation'), - `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, - ); - }); - - test('expected_phase_dir omits prefix when project_code is not set', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({}), - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', - ); - - const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `init plan-phase failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase_dir, null); - assert.ok( - typeof output.expected_phase_dir === 'string', - `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, - ); - assert.ok( - !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), - `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, - ); - }); -}); diff --git a/tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs b/tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs deleted file mode 100644 index 0026b2ddf..000000000 --- a/tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs +++ /dev/null @@ -1,171 +0,0 @@ -'use strict'; -/** - * Regression test for #3298 — phase-dir prefix drift in /gsd-plan-milestone-gaps, - * /gsd-import, and /gsd-capture --backlog workflows (PRED.k015 sibling audit). - * - * Projects with `project_code` set in `.planning/config.json` must have - * consistent `--` directory naming across ALL phase-creation - * paths. PR #3292 (#3287) fixed `/gsd-discuss-phase` and `/gsd-plan-phase`. - * - * Missed sites (this PR): - * 1. `plan-milestone-gaps.md` step 8 — raw `{NN}-{name}` mkdir pattern. - * 2. `import.md` plan_convert step — raw `{NN}-{slug}` mkdir pattern. - * 3. `add-backlog.md` step 4 — raw `${NEXT}-${SLUG}` mkdir pattern - * (backlog uses 999.x numbering; still subject to project_code prefix). - * - * The fix: all three files must resolve the directory name via `init.phase-op` - * (which exposes `expected_phase_dir` with the project_code prefix) or use - * a `project_code`-aware helper before calling mkdir. - * - * Tests are structural (parse-level) — no source-grep on raw strings. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const PMG_WF = path.join( - __dirname, '..', 'get-shit-done', 'workflows', 'plan-milestone-gaps.md', -); -const IMPORT_WF = path.join( - __dirname, '..', 'get-shit-done', 'workflows', 'import.md', -); -const BACKLOG_WF = path.join( - __dirname, '..', 'get-shit-done', 'workflows', 'add-backlog.md', -); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -function readWorkflow(filePath) { - try { - return fs.readFileSync(filePath, 'utf8'); - } catch (err) { - throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`); - } -} - -/** - * Returns true when the content contains a bare `mkdir -p ".planning/phases/{NN}-{name}"` - * or `mkdir -p ".planning/phases/{NN}-{slug}"` pattern that does NOT include - * a `project_code`/`expected_phase_dir` variable — i.e., the unfixed drift pattern. - * - * We look for `mkdir` lines that reference .planning/phases/ where the directory - * component starts with `{` (template literal, no variable substitution). - */ -function containsBareTemplateMkdir(content) { - // Match lines like: mkdir -p ".planning/phases/{NN}-{name}" - // or: mkdir -p ".planning/phases/{NN}-{slug}/" - // These are the drift patterns — they don't use expected_phase_dir. - return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content); -} - -/** - * Returns true when the content contains a bare shell-variable mkdir pattern like: - * mkdir -p ".planning/phases/${NEXT}-${SLUG}" - * without a project_code prefix variable before `${NEXT}` (or similar). - * - * The drift pattern is: directory path starts with `${NEXT}` (or `${NN}`) directly, - * with no preceding `${PREFIX}` or `${CODE}` variable that would carry project_code. - */ -function containsBareShellVarMkdir(content) { - // Match mkdir lines where the phases/ directory component starts with a bare - // shell variable like ${NEXT} or ${NN} — no prefix variable before it. - // Positive match: mkdir .../phases/${NEXT}- or .../phases/${NN}- - // We exclude lines that have a variable BEFORE ${NEXT}/${NN} (i.e., a prefix var). - return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content) - || /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content); -} - -// ─── plan-milestone-gaps.md ─────────────────────────────────────────────────── - -describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => { - test('workflow file exists', () => { - assert.ok( - fs.existsSync(PMG_WF), - `plan-milestone-gaps.md must exist at ${PMG_WF}`, - ); - }); - - test('step 8 must not use bare {NN}-{name} mkdir pattern', () => { - const content = readWorkflow(PMG_WF); - assert.ok( - !containsBareTemplateMkdir(content), - 'plan-milestone-gaps.md must not contain bare mkdir .planning/phases/{NN}-{name} pattern — use phase.add or expected_phase_dir', - ); - }); - - test('step 8 must use expected_phase_dir or phase.add for directory creation', () => { - const content = readWorkflow(PMG_WF); - const usesExpectedPhaseDir = content.includes('expected_phase_dir'); - const usesPhaseAdd = content.includes('phase.add'); - assert.ok( - usesExpectedPhaseDir || usesPhaseAdd, - 'plan-milestone-gaps.md must use expected_phase_dir (from init.phase-op) or phase.add to create phase directories with project_code prefix', - ); - }); -}); - -// ─── import.md ─────────────────────────────────────────────────────────────── - -describe('bug-3298 — import.md must not construct bare {NN}-{slug} phase dirs', () => { - test('workflow file exists', () => { - assert.ok( - fs.existsSync(IMPORT_WF), - `import.md must exist at ${IMPORT_WF}`, - ); - }); - - test('plan_convert step must not use bare {NN}-{slug} mkdir pattern', () => { - const content = readWorkflow(IMPORT_WF); - assert.ok( - !containsBareTemplateMkdir(content), - 'import.md must not contain bare mkdir .planning/phases/{NN}-{slug} pattern — use expected_phase_dir from init.phase-op', - ); - }); - - test('plan_convert step must use expected_phase_dir for directory creation', () => { - const content = readWorkflow(IMPORT_WF); - assert.ok( - content.includes('expected_phase_dir'), - 'import.md must use expected_phase_dir (from init.phase-op) to create phase directory with project_code prefix', - ); - }); - - test('plan_convert step must call init.phase-op to resolve the prefixed dir', () => { - const content = readWorkflow(IMPORT_WF); - assert.ok( - content.includes('init.phase-op') || content.includes('init phase-op'), - 'import.md must call gsd-sdk query init.phase-op to get expected_phase_dir with project_code prefix', - ); - }); -}); - -// ─── add-backlog.md (sibling site found during k015 audit) ─────────────────── - -describe('bug-3298 — add-backlog.md must apply project_code prefix when creating 999.x dirs', () => { - test('workflow file exists', () => { - assert.ok( - fs.existsSync(BACKLOG_WF), - `add-backlog.md must exist at ${BACKLOG_WF}`, - ); - }); - - test('step 4 must not use bare ${NEXT}-${SLUG} mkdir without project_code prefix', () => { - const content = readWorkflow(BACKLOG_WF); - assert.ok( - !containsBareShellVarMkdir(content), - 'add-backlog.md must not create .planning/phases/${NEXT}-${SLUG} without a project_code prefix variable — apply ${PREFIX} (or equivalent) before ${NEXT}', - ); - }); - - test('step 4 must reference project_code or a prefix variable before the phase number', () => { - const content = readWorkflow(BACKLOG_WF); - const hasProjectCodeRef = content.includes('project_code') || content.includes('PROJECT_CODE'); - const hasPrefixVar = content.includes('${PREFIX}') || content.includes('${PHASE_PREFIX}') || content.includes('${CODE}'); - assert.ok( - hasProjectCodeRef || hasPrefixVar, - 'add-backlog.md must read project_code (or use a PREFIX variable) to apply the project_code prefix to the 999.x phase directory name', - ); - }); -}); diff --git a/tests/bug-3517-phase-complete-state-md-staleness.test.cjs b/tests/bug-3517-phase-complete-state-md-staleness.test.cjs deleted file mode 100644 index e0a4335d8..000000000 --- a/tests/bug-3517-phase-complete-state-md-staleness.test.cjs +++ /dev/null @@ -1,417 +0,0 @@ -// allow-test-rule: state-md-is-the-runtime-contract — this test asserts the -// exact STATE.md fields written by phase.complete; STATE.md IS the product -// surface being verified, not source code. Same justification pattern as -// tests/bug-2698-crlf-install.test.cjs. Migration to typed-IR parser tracked -// in #2974. - -/** - * Regression tests for bug #3517: - * `gsd-sdk query phase.complete N` returns state_updated: true but - * leaves STATE.md with stale fields. - * - * Root cause 1 (idempotency): completed_phases is blindly incremented - * (parseInt(match[1], 10) + 1). Running phase.complete N twice - * double-counts: 4 → 5 → 6 across two phase.complete 5 runs. - * - * Root cause 2 (field coverage): only 4 of ~10 stale STATE.md fields - * get refreshed. Specifically missing: - * - frontmatter: stopped_at, last_updated, total_plans, completed_plans - * - body: Current focus, Status line, Progress bar, Velocity block, - * By Phase table row for the completed phase - * - * Fix: derive completed_phases by counting Complete rows in ROADMAP - * progress table; update all stale fields in the same atomic write. - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { execFileSync } = require('node:child_process'); - -const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js'); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function runSdkQuery(args, cwd) { - try { - const result = execFileSync(process.execPath, [SDK_CLI, 'query', ...args], { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - const parsed = JSON.parse(result.trim()); - return { success: true, data: parsed }; - } catch (err) { - const stderr = err.stderr?.toString().trim() || ''; - const stdout = err.stdout?.toString().trim() || ''; - try { - const parsed = JSON.parse(stdout); - return { success: true, data: parsed }; - } catch { /* not JSON */ } - return { success: false, error: stderr || err.message }; - } -} - -function setupProject(tmpDir) { - const planningDir = path.join(tmpDir, '.planning'); - const phasesDir = path.join(planningDir, 'phases'); - fs.mkdirSync(planningDir, { recursive: true }); - fs.mkdirSync(phasesDir, { recursive: true }); - - // config.json — required for planningPaths - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ project_code: 'TEST' }), - ); - - // ROADMAP.md — 3 phases, phase 5 already complete in table, 6 in progress - const roadmap = [ - '# Roadmap', - '', - '## Current Milestone: v3.0', - '', - '| Phase | Plans | Status | Completed |', - '|-------|-------|--------|-----------|', - '| 4. | 3/3 | Complete | 2026-04-01 |', - '| 5. | 7/7 | In Progress | |', - '| 6. | 0/5 | Not Started | |', - '', - '- [x] Phase 4: Foundation (completed 2026-04-01)', - '- [ ] Phase 5: Core API', - '- [ ] Phase 6: Integration', - '', - '### Phase 4: Foundation', - '', - '**Goal:** Foundation work', - '**Plans:** 3/3 plans complete', - '', - '### Phase 5: Core API', - '', - '**Goal:** Build core API layer', - '**Plans:** 7 plans', - '', - 'Plans:', - '- [ ] 05-01 plan', - '- [ ] 05-02 plan', - '- [ ] 05-03 plan', - '- [ ] 05-04 plan', - '- [ ] 05-05 plan', - '- [ ] 05-06 plan', - '- [ ] 05-07 plan', - '', - '### Phase 6: Integration', - '', - '**Goal:** Integration work', - '**Plans:** 5 plans', - '', - '---', - '*Last updated: 2026-05-14*', - ].join('\n'); - - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); - - // STATE.md — initial state: phase 5 in progress, 1 of 3 phases complete - // frontmatter has total_plans, completed_plans, stopped_at, last_updated - const state = [ - '---', - 'gsd_state_version: 1.0', - 'milestone: v3.0', - 'milestone_name: Core Platform', - 'status: executing', - 'stopped_at: Completed 05-03-PLAN.md', - 'last_updated: 2026-05-10T08:00:00.000Z', - 'progress:', - ' total_phases: 3', - ' completed_phases: 1', - ' total_plans: 15', - ' completed_plans: 6', - ' percent: 33', - '---', - '', - '# Project State', - '', - '## Current Position', - '', - '**Current focus:** Phase 5 — Core API', - 'Phase: 5 of 3 (Core API) — EXECUTING', - 'Plan: 7 of 7', - 'Status: Executing Phase 5', - 'Last activity: 2026-05-10', - '', - '## Progress', - '', - 'Progress: [████████████░░░░] 40% (1/3 phases complete before Phase 5 closeout)', - '', - '## Performance Metrics', - '', - '**Velocity:**', - '', - '- Total plans completed: 6', - '- Average duration: 2h', - '- Total execution time: 14 hours', - '- Window: 2026-04-01 to 2026-05-10', - '', - '**By Phase:**', - '', - '| Phase | Plans | Total | Avg/Plan |', - '|-------|-------|-------|----------|', - '| 4 | 3 | - | - |', - '', - '## Session Continuity', - '', - 'Last session: 2026-05-10T08:00:00.000Z', - 'Stopped at: Completed 05-07-PLAN.md', - ].join('\n'); - - fs.writeFileSync(path.join(planningDir, 'STATE.md'), state); - - // Phase 5 directory with 7 plans and 7 summaries (all complete) - const phase5Dir = path.join(phasesDir, '05-core-api'); - fs.mkdirSync(phase5Dir, { recursive: true }); - for (let i = 1; i <= 7; i++) { - const padded = String(i).padStart(2, '0'); - fs.writeFileSync(path.join(phase5Dir, `05-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); - fs.writeFileSync(path.join(phase5Dir, `05-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); - } - - // Phase 4 directory (already complete) - const phase4Dir = path.join(phasesDir, '04-foundation'); - fs.mkdirSync(phase4Dir, { recursive: true }); - for (let i = 1; i <= 3; i++) { - const padded = String(i).padStart(2, '0'); - fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); - fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); - } - - // Phase 6 directory (not started — no plans or summaries yet) - const phase6Dir = path.join(phasesDir, '06-integration'); - fs.mkdirSync(phase6Dir, { recursive: true }); - - return { planningDir, phase5Dir }; -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3517-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('completed_phases is derived from ROADMAP, not blindly incremented (idempotency)', () => { - // Root cause 1: integer += 1 means phase.complete 5 twice yields 3. - // Fix: count Complete rows in ROADMAP progress table after the update. - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - // First call — should set completed_phases = 2 (phase 4 + phase 5 = 2 complete) - const r1 = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r1.success, `first call failed: ${r1.error}`); - - const stateAfter1 = fs.readFileSync(statePath, 'utf8'); - const match1 = stateAfter1.match(/completed_phases:\s*(\d+)/); - assert.ok(match1, 'completed_phases not found in frontmatter after first call'); - assert.equal( - Number(match1[1]), - 2, - `After first call: completed_phases should be 2 (derived from ROADMAP: phases 4 and 5 complete), got ${match1[1]}`, - ); - - // Second call on the same phase — must NOT increment again - const r2 = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r2.success, `second call failed: ${r2.error}`); - - const stateAfter2 = fs.readFileSync(statePath, 'utf8'); - const match2 = stateAfter2.match(/completed_phases:\s*(\d+)/); - assert.ok(match2, 'completed_phases not found in frontmatter after second call'); - assert.equal( - Number(match2[1]), - 2, - `After second call (same phase): completed_phases must remain 2 (idempotent), got ${match2[1]}`, - ); - }); - - test('frontmatter stopped_at is updated after phase.complete', () => { - // Bug: stopped_at stays as "Completed 05-03-PLAN.md" after phase.complete 5 - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - - const state = fs.readFileSync(statePath, 'utf8'); - // stopped_at should now reflect phase 5 completion, not the old plan progress - const stoppedMatch = state.match(/stopped_at:\s*(.+)/); - assert.ok(stoppedMatch, 'stopped_at not found in frontmatter'); - assert.ok( - !stoppedMatch[1].includes('05-03-PLAN.md'), - `stopped_at should not still say "Completed 05-03-PLAN.md" — got: ${stoppedMatch[1]}`, - ); - assert.ok( - stoppedMatch[1].toLowerCase().includes('phase 5') || - stoppedMatch[1].toLowerCase().includes('complete'), - `stopped_at should reference phase 5 completion, got: ${stoppedMatch[1]}`, - ); - }); - - test('frontmatter last_updated is refreshed to today after phase.complete', () => { - // Bug: last_updated stays as 2026-05-10T08:00:00.000Z (stale) - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - - const state = fs.readFileSync(statePath, 'utf8'); - const lastUpdatedMatch = state.match(/last_updated:\s*(.+)/); - assert.ok(lastUpdatedMatch, 'last_updated not found in frontmatter'); - // Should not still be the stale value from before the call - assert.notEqual( - lastUpdatedMatch[1].trim(), - '2026-05-10T08:00:00.000Z', - `last_updated must be refreshed, but it is still the stale value: ${lastUpdatedMatch[1]}`, - ); - // Should be a recent ISO date (within 1 minute of now) - const updatedAt = new Date(lastUpdatedMatch[1].trim()); - const now = new Date(); - const diffMs = Math.abs(now - updatedAt); - assert.ok( - diffMs < 60_000, - `last_updated should be approximately now (within 60s), got: ${lastUpdatedMatch[1]} (diff: ${diffMs}ms)`, - ); - }); - - test('frontmatter total_plans is updated from ROADMAP plan counts after phase.complete', () => { - // Bug: total_plans stays as 15 even though phase 5 had 7 plans (not the initially-estimated count) - // After completing phase 5 (7/7), ROADMAP now shows: 4→3 plans, 5→7 plans, 6→5 plans = 15 total - // But total_plans in frontmatter is NOT refreshed from actual ROADMAP state. - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - - const state = fs.readFileSync(statePath, 'utf8'); - const match = state.match(/total_plans:\s*(\d+)/); - assert.ok(match, 'total_plans not found in frontmatter'); - // total_plans should be derived from ROADMAP: 3 + 7 + 5 = 15 - // The key test: it must NOT be the exact stale value if the actual sum differs, - // meaning it was recomputed (not left untouched). - // We verify it's a number and consistent (not undefined/unchanged from initial stale value - // when the sum has changed — in this fixture the sum is 15 both ways, so we test - // that the field exists and is numeric, not stale in the case it would diverge). - const totalPlans = Number(match[1]); - assert.ok(Number.isFinite(totalPlans) && totalPlans > 0, `total_plans must be a positive number, got: ${match[1]}`); - }); - - test('frontmatter completed_plans is updated from SUMMARY file count after phase.complete', () => { - // Bug: completed_plans stays as 6 even though completing phase 5 adds 7 more summaries = 13 total - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - - const state = fs.readFileSync(statePath, 'utf8'); - const match = state.match(/completed_plans:\s*(\d+)/); - assert.ok(match, 'completed_plans not found in frontmatter'); - const completedPlans = Number(match[1]); - // Phase 4 has 3 summaries + Phase 5 has 7 summaries = 10 total on disk - assert.equal( - completedPlans, - 10, - `completed_plans should be 10 (3 phase-4 summaries + 7 phase-5 summaries), got: ${completedPlans}`, - ); - }); - - test('frontmatter percent is recomputed from fresh derived counts', () => { - // Bug: percent is recomputed but from the blind-incremented completed_phases, - // not from the ROADMAP-derived value. - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - - const state = fs.readFileSync(statePath, 'utf8'); - const match = state.match(/percent:\s*(\d+)/); - assert.ok(match, 'percent not found in frontmatter'); - // 2 of 3 phases complete = 67% - assert.equal(Number(match[1]), 67, `percent should be 67 (2/3 phases), got: ${match[1]}`); - }); - - test('body Current focus is updated to next phase after phase.complete', () => { - // Bug: "Current focus: Phase 5 — Core API" is never updated - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - - const state = fs.readFileSync(statePath, 'utf8'); - // After completing phase 5, current focus should reference phase 6 - assert.ok( - !state.includes('Current focus:** Phase 5') && !state.includes('Current focus: Phase 5'), - `"Current focus:" should no longer reference Phase 5 after it is complete.\nState:\n${state}`, - ); - }); - - test('body By Phase table row for completed phase shows correct plan count', () => { - // Bug: By Phase table row for phase 5 doesn't show the actual 7/7 plans - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - - const state = fs.readFileSync(statePath, 'utf8'); - // By Phase table should have a row for phase 5 with 7 plans - assert.match( - state, - /\|\s*5\s*\|\s*7\s*\|/, - `By Phase table should have a row for phase 5 with 7 summaries.\nState:\n${state}`, - ); - }); - - test('full consistency check: all STATE.md fields are coherent after phase.complete', () => { - // Integration assertion: after phase.complete 5, every field should be - // consistent with "phase 5 just completed, phase 6 is next." - - setupProject(tmpDir); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - const r = runSdkQuery(['phase.complete', '5'], tmpDir); - assert.ok(r.success, `call failed: ${r.error}`); - assert.equal(r.data?.state_updated, true, 'state_updated must be true'); - - const state = fs.readFileSync(statePath, 'utf8'); - - // Frontmatter checks - assert.match(state, /completed_phases:\s*2/, 'completed_phases must be 2 (4 and 5 complete)'); - assert.match(state, /percent:\s*67/, 'percent must be 67%'); - - // Body checks - assert.match(state, /Status:\s*Ready to plan/, 'Status must be "Ready to plan" (next phase exists)'); - - // Phase 6 should appear in state (next phase); accept zero-padded "06" too - const hasPhase6 = /Phase:\s*0?6/.test(state) || /current_phase:\s*0?6/.test(state); - assert.ok(hasPhase6, `STATE.md must reference Phase 6 as current after completing Phase 5.\nState:\n${state}`); - }); -}); diff --git a/tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs b/tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs deleted file mode 100644 index 26b368749..000000000 --- a/tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Bug #3601: `phase remove N` for an integer phase can also delete the - * adjacent decimal phase section (`### Phase N.1:`) when the decimal is a - * peer-level header at the same depth as the integer being removed. - * - * Root cause: the section-removal regex in - * `get-shit-done/bin/lib/phase.cjs:updateRoadmapAfterPhaseRemoval` used a - * depth-blind lookahead (`(?=\n#{2,4}\s+Phase\s+\d+\s*:|$)`) that required - * the next header's digits to be followed by `\s*:`. `### Phase 2.1:` - * (depth 3, decimal) did not satisfy `\d+\s*:` because of the `.1`, so - * the non-greedy match consumed `Phase 2.1` along with `Phase 2` until it - * reached the next integer header. - * - * The fix makes the lookahead depth-aware: it captures the hash count of - * the header being removed and stops only at a subsequent header of the - * SAME depth, integer or decimal. That preserves the #3355 contract - * (`#### Phase 27.1:` at depth 4 is a CHILD of `### Phase 27:` at depth 3 - * and must be removed alongside it) while fixing the #3601 contract - * (`### Phase 2.1:` at depth 3 is a PEER of `### Phase 2:` and must be - * preserved). - * - * Assertions go through the typed `roadmap get-phase --json` query so no - * test asserts on raw ROADMAP.md text content. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -function writeRoadmap(tmpDir, body) { - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); -} -function writeState(tmpDir, version) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `---\nmilestone: ${version}\n---\n`, - ); -} -function ensurePhaseDir(tmpDir, name) { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true }); -} -function getPhase(tmpDir, phaseNum) { - const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir); - if (!r.success) return { found: false, error: r.error }; - return JSON.parse(r.output); -} - -describe('bug #3601: phase remove preserves peer-depth decimal sections', () => { - let tmpDir; - beforeEach(() => { - tmpDir = createTempProject('bug-3601-'); - }); - afterEach(() => { - cleanup(tmpDir); - }); - - test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => { - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0 - Test', - '', - '### Phase 2: Parent', - '**Goal:** RemoveMeGoal', - '', - '### Phase 2.1: Follow-up', - '**Goal:** PreserveDecimalGoal', - '', - '### Phase 3: Trailing', - '**Goal:** PreserveTrailingGoal', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, '02-parent'); - ensurePhaseDir(tmpDir, '02.1-follow-up'); - ensurePhaseDir(tmpDir, '03-trailing'); - - const r = runGsdTools(['phase', 'remove', '2'], tmpDir); - assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); - - // The peer-depth decimal (Phase 2.1) must still be queryable — its - // unique goal proves the section body survived. - const decimal = getPhase(tmpDir, '2.1'); - assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2'); - assert.strictEqual(decimal.phase_name, 'Follow-up'); - assert.strictEqual(decimal.goal, 'PreserveDecimalGoal'); - - // Phase 3 must have been renumbered to Phase 2. - const renumbered = getPhase(tmpDir, '2'); - assert.strictEqual(renumbered.found, true); - assert.strictEqual(renumbered.phase_name, 'Trailing'); - assert.strictEqual( - renumbered.goal, - 'PreserveTrailingGoal', - 'Phase 3 → Phase 2 renumber did not carry the right section content', - ); - - // The removed Parent goal must not be retrievable from any current phase. - const parentLookup = getPhase(tmpDir, '3'); - assert.notStrictEqual( - parentLookup.goal, - 'RemoveMeGoal', - 'removed parent goal reappeared under a phase header', - ); - }); - - test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => { - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0 - Test', - '', - '### Phase 5: Parent', - '**Goal:** RemoveParent', - '', - '### Phase 5.1: First child', - '**Goal:** ChildAGoal', - '', - '### Phase 5.2: Second child', - '**Goal:** ChildBGoal', - '', - '### Phase 6: Tail', - '**Goal:** TailGoal', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, '05-parent'); - ensurePhaseDir(tmpDir, '05.1-first-child'); - ensurePhaseDir(tmpDir, '05.2-second-child'); - ensurePhaseDir(tmpDir, '06-tail'); - - const r = runGsdTools(['phase', 'remove', '5'], tmpDir); - assert.ok(r.success); - - const decimalA = getPhase(tmpDir, '5.1'); - assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted'); - assert.strictEqual(decimalA.goal, 'ChildAGoal'); - - const decimalB = getPhase(tmpDir, '5.2'); - assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted'); - assert.strictEqual(decimalB.goal, 'ChildBGoal'); - - const tail = getPhase(tmpDir, '5'); - assert.strictEqual(tail.found, true); - assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired'); - }); -}); diff --git a/tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs b/tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs deleted file mode 100644 index c64aee867..000000000 --- a/tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Bug #3602: After `phase remove N`, ROADMAP references to slugged plan - * files (`07-01-cherry-pick-foundation-PLAN.md`) keep their old phase - * prefix while the on-disk file has already been renamed. The - * renumber-references regex in `phase.cjs` only matched compact forms - * (`07-01-PLAN.md`, bare `07-01`) — it did not allow a slug between the - * plan number and the `-PLAN.md` / `-SUMMARY.md` suffix. - * - * Fix: extend the lookahead to allow an optional `-` segment before - * the `-(PLAN|SUMMARY).md` suffix while still preserving the "bare token, - * not part of a longer number/identifier" alternative. - * - * Assertions go through the typed `roadmap get-phase --json` query so no - * test asserts on raw ROADMAP.md text content. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -function writeRoadmap(tmpDir, body) { - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); -} -function writeState(tmpDir, version) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `---\nmilestone: ${version}\n---\n`, - ); -} -function ensurePhaseDir(tmpDir, name) { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true }); -} -function ensurePlanFile(tmpDir, phaseDirName, planName) { - const p = path.join(tmpDir, '.planning', 'phases', phaseDirName, planName); - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, '# Plan\n'); -} -function getPhase(tmpDir, phaseNum) { - const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir); - if (!r.success) return { found: false, error: r.error }; - return JSON.parse(r.output); -} - -describe('bug #3602: phase remove renumbers slugged plan references in ROADMAP', () => { - let tmpDir; - beforeEach(() => { - tmpDir = createTempProject('bug-3602-'); - }); - afterEach(() => { - cleanup(tmpDir); - }); - - test('slugged PLAN reference (07-01-cherry-pick-foundation-PLAN.md) is renumbered to 06-01-…', () => { - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0', - '', - '### Phase 6: Old Work', - '**Goal:** RemoveThisGoal', - '', - '### Phase 7: New Work', - '**Goal:** Plans: 07-01-cherry-pick-foundation-PLAN.md and 07-02-finish-it-SUMMARY.md', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, '06-old'); - ensurePhaseDir(tmpDir, '07-new'); - ensurePlanFile(tmpDir, '07-new', '07-01-cherry-pick-foundation-PLAN.md'); - ensurePlanFile(tmpDir, '07-new', '07-02-finish-it-SUMMARY.md'); - - const r = runGsdTools(['phase', 'remove', '6'], tmpDir); - assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); - - // Phase 7 → Phase 6 after removal. The renumbered phase's recorded - // goal must reference the renumbered plan filenames (06-01-… and - // 06-02-…), not the stale 07-01-… / 07-02-… form. - const phase6 = getPhase(tmpDir, '6'); - assert.strictEqual(phase6.found, true); - assert.strictEqual(phase6.phase_name, 'New Work'); - assert.ok( - phase6.goal.includes('06-01-cherry-pick-foundation-PLAN.md'), - `Plans reference for slugged PLAN was not renumbered. Goal: ${phase6.goal}`, - ); - assert.ok( - phase6.goal.includes('06-02-finish-it-SUMMARY.md'), - `Plans reference for slugged SUMMARY was not renumbered. Goal: ${phase6.goal}`, - ); - assert.ok( - !phase6.goal.includes('07-01'), - `stale 07-01 prefix remains in ROADMAP. Goal: ${phase6.goal}`, - ); - assert.ok( - !phase6.goal.includes('07-02'), - `stale 07-02 prefix remains in ROADMAP. Goal: ${phase6.goal}`, - ); - }); - - test('compact PLAN/SUMMARY reference (07-01-PLAN.md) still renumbers (#3601 contract preserved)', () => { - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0', - '', - '### Phase 6: Old', - '**Goal:** RemoveGoal', - '', - '### Phase 7: New', - '**Goal:** Plans: 07-01-PLAN.md and 07-02-SUMMARY.md', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, '06-old'); - ensurePhaseDir(tmpDir, '07-new'); - ensurePlanFile(tmpDir, '07-new', '07-01-PLAN.md'); - ensurePlanFile(tmpDir, '07-new', '07-02-SUMMARY.md'); - - const r = runGsdTools(['phase', 'remove', '6'], tmpDir); - assert.ok(r.success); - - const phase6 = getPhase(tmpDir, '6'); - assert.strictEqual(phase6.found, true); - assert.ok(phase6.goal.includes('06-01-PLAN.md')); - assert.ok(phase6.goal.includes('06-02-SUMMARY.md')); - assert.ok(!phase6.goal.includes('07-01-PLAN.md')); - assert.ok(!phase6.goal.includes('07-02-SUMMARY.md')); - }); - - test('does NOT renumber values that look like phase-plan tokens but are not (e.g. 2026-01-01 dates)', () => { - // Counter-test: an ISO date `2026-01-01` should NOT be matched by the - // renumber regex even though it superficially looks like NN-NN-NN. - // The negative lookbehind `(? { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('prunes STATE.md automatically when auto_prune_state is true', () => { - writeConfig(tmpDir, { - workflow: { auto_prune_state: true }, - }); - - writeStateMd(tmpDir, [ - '# Session State', - '', - '**Current Phase:** 6', - '**Status:** Executing', - '', - '## Decisions', - '', - '- [Phase 1]: Old decision from phase 1', - '- [Phase 2]: Old decision from phase 2', - '- [Phase 5]: Recent decision', - '- [Phase 6]: Current decision', - '', - ].join('\n')); - - writeRoadmap(tmpDir, [ - '# Roadmap', - '', - '## Phase 6: Test Phase', - '', - '**Plans:** 0/2', - '', - ].join('\n')); - - setupPhase(tmpDir, 6, 2); - - const result = runGsdTools('phase complete 6', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const newState = readStateMd(tmpDir); - // With keep-recent=3 (default), cutoff = 6-3 = 3 - // Phase 1 and 2 decisions should be pruned - assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/); - assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/); - // Phase 5 and 6 should remain - assert.match(newState, /\[Phase 5\]: Recent decision/); - assert.match(newState, /\[Phase 6\]: Current decision/); - }); - - test('does NOT prune when auto_prune_state is false (default)', () => { - writeConfig(tmpDir, { - workflow: { auto_prune_state: false }, - }); - - writeStateMd(tmpDir, [ - '# Session State', - '', - '**Current Phase:** 6', - '**Status:** Executing', - '', - '## Decisions', - '', - '- [Phase 1]: Old decision from phase 1', - '- [Phase 5]: Recent decision', - '- [Phase 6]: Current decision', - '', - ].join('\n')); - - writeRoadmap(tmpDir, [ - '# Roadmap', - '', - '## Phase 6: Test Phase', - '', - '**Plans:** 0/2', - '', - ].join('\n')); - - setupPhase(tmpDir, 6, 2); - - const result = runGsdTools('phase complete 6', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const newState = readStateMd(tmpDir); - // Phase 1 decision should still be present (no pruning) - assert.match(newState, /\[Phase 1\]: Old decision/); - }); - - test('does NOT prune when auto_prune_state is absent from config', () => { - writeConfig(tmpDir, { - workflow: {}, - }); - - writeStateMd(tmpDir, [ - '# Session State', - '', - '**Current Phase:** 6', - '**Status:** Executing', - '', - '## Decisions', - '', - '- [Phase 1]: Old decision from phase 1', - '- [Phase 6]: Current decision', - '', - ].join('\n')); - - writeRoadmap(tmpDir, [ - '# Roadmap', - '', - '## Phase 6: Test Phase', - '', - '**Plans:** 0/2', - '', - ].join('\n')); - - setupPhase(tmpDir, 6, 2); - - const result = runGsdTools('phase complete 6', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const newState = readStateMd(tmpDir); - // Should not prune — absent means disabled (default: false) - assert.match(newState, /\[Phase 1\]: Old decision/); - }); -}); diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 84541896b..7fe68898a 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -1,16 +1,29 @@ // allow-test-rule: source-text-is-the-product // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. +// allow-test-rule: state-md-is-the-runtime-contract — regression tests for +// bug #3517 assert the exact STATE.md fields written by phase.complete; +// STATE.md IS the product surface being verified, not source code. +// Migration to typed-IR parser tracked in #2974. /** * GSD Tools Tests - Phase + * + * Consolidated from 20 → 4 test files (issue #3740). This file covers the + * CJS phase CLI layer (phase.cjs + gsd-tools.cjs): add, remove, complete, + * list, insert, and all regression tests for bugs in that layer. */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs'); + +const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); +const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js'); describe('phases list command', () => { let tmpDir; @@ -2971,5 +2984,1583 @@ describe('phase complete excludes 999.x backlog from next-phase (#2129)', () => }); // ───────────────────────────────────────────────────────────────────────────── -// milestone complete command +// regression: bug #1962 — normalizePhaseName preserves letter suffix case +// (consolidated from tests/bug-1962-phase-suffix-case.test.cjs) // ───────────────────────────────────────────────────────────────────────────── + +describe('bug #1962: normalizePhaseName preserves letter suffix case', () => { + test('lowercase suffix preserved: 16c → 16c', () => { + assert.equal(normalizePhaseName('16c'), '16c'); + }); + + test('uppercase suffix preserved: 16C → 16C', () => { + assert.equal(normalizePhaseName('16C'), '16C'); + }); + + test('single digit padded with lowercase suffix: 1a → 01a', () => { + assert.equal(normalizePhaseName('1a'), '01a'); + }); + + test('single digit padded with uppercase suffix: 1A → 01A', () => { + assert.equal(normalizePhaseName('1A'), '01A'); + }); + + test('no suffix unchanged: 16 → 16', () => { + assert.equal(normalizePhaseName('16'), '16'); + }); + + test('decimal suffix preserved: 16.1 → 16.1', () => { + assert.equal(normalizePhaseName('16.1'), '16.1'); + }); + + test('letter + decimal preserved: 16c.2 → 16c.2', () => { + assert.equal(normalizePhaseName('16c.2'), '16c.2'); + }); + + test('project code prefix stripped, suffix case preserved: CK-01a → 01a', () => { + assert.equal(normalizePhaseName('CK-01a'), '01a'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// regression: bug #1998 — phase complete updates overview checkbox +// (consolidated from tests/bug-1998-phase-complete-checkbox.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +describe('bug #1998: phase complete updates overview checkbox', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1998-')); + planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + + // Minimal config + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ project_code: 'TEST' }) + ); + + // Minimal STATE.md + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('checkbox updated when no archived milestones exist', () => { + const phasesDir = path.join(planningDir, 'phases', '01-foundation'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '## Phases', + '', + '- [ ] **Phase 1: Foundation** - core setup', + '- [ ] **Phase 2: Features** - add features', + '', + '## Progress', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Foundation | 0/1 | Pending | - |', + '| 2. Features | 0/1 | Pending | - |', + ].join('\n')); + + try { + execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); + } catch { + // Command may exit non-zero if STATE.md update fails, but ROADMAP.md update happens first + } + + const result = fs.readFileSync(roadmapPath, 'utf-8'); + assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked'); + assert.match(result, /- \[ \] \*\*Phase 2: Features\*\*/, 'phase 2 checkbox should remain unchecked'); + }); + + test('checkbox updated when archived milestones exist in
', () => { + const phasesDir = path.join(planningDir, 'phases', '01-setup'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap v2.0', + '', + '## Phases', + '', + '- [ ] **Phase 1: Setup** - initial setup', + '- [ ] **Phase 2: Build** - build features', + '', + '## Progress', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Setup | 0/1 | Pending | - |', + '| 2. Build | 0/1 | Pending | - |', + '', + '
', + 'v1.0 (Archived)', + '', + '## v1.0 Phases', + '- [x] **Phase 1: Init** - initialization', + '- [x] **Phase 2: Deploy** - deployment', + '', + '
', + ].join('\n')); + + try { + execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); + } catch { + // May exit non-zero + } + + const result = fs.readFileSync(roadmapPath, 'utf-8'); + assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked'); + assert.match(result, /- \[ \] \*\*Phase 2: Build\*\*/, 'phase 2 checkbox should remain unchecked'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// regression: bug #2005 — phase complete inside
updates plan count +// (consolidated from tests/bug-2005-phase-complete-details.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +describe('bug #2005: phase complete updates plan count when milestone is inside
', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2005-')); + planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ project_code: 'TEST' }) + ); + + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + '---\ncurrent_phase: 1\nstatus: executing\nmilestone: v2.0\n---\n# State\n' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('plan count is updated when current milestone is wrapped in
', () => { + const phasesDir = path.join(planningDir, 'phases', '01-setup'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '
', + 'v1.0 (shipped)', + '', + '## v1.0 Phases', + '- [x] **Phase 0: Bootstrap** - shipped', + '', + '
', + '', + '
', + 'v2.0 (in progress)', + '', + '## v2.0 Phases', + '', + '- [ ] **Phase 1: Setup** - initial setup', + '- [ ] **Phase 2: Build** - build features', + '', + '## Progress', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Setup | 0/1 | Pending | - |', + '| 2. Build | 0/1 | Pending | - |', + '', + '### Phase 1: Setup', + '', + '**Plans:** 0/1 plans complete', + '', + '### Phase 2: Build', + '', + '**Plans:** 0/1 plans complete', + '', + '
', + ].join('\n')); + + try { + execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); + } catch { + // May exit non-zero if STATE.md update fails, but ROADMAP.md update is the target + } + + const result = fs.readFileSync(roadmapPath, 'utf-8'); + + assert.match( + result, + /\*\*Plans:\*\*\s*1\/1 plans complete/, + 'plan count in Phase 1 section must be updated to 1/1 plans complete' + ); + assert.match( + result, + /- \[x\] \*\*Phase 1: Setup\*\*/, + 'Phase 1 checkbox must be checked after completion' + ); + assert.match( + result, + /- \[ \] \*\*Phase 2: Build\*\*/, + 'Phase 2 checkbox must remain unchecked' + ); + }); + + test('phase complete with all milestones in
does not corrupt phase 2 plan count', () => { + const phasesDir = path.join(planningDir, 'phases', '01-setup'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '
', + 'v2.0 (in progress)', + '', + '## v2.0 Phases', + '', + '- [ ] **Phase 1: Setup** - initial setup', + '- [ ] **Phase 2: Build** - build features', + '', + '### Phase 1: Setup', + '', + '**Plans:** 0/1 plans complete', + '', + '### Phase 2: Build', + '', + '**Plans:** 0/2 plans complete', + '', + '
', + ].join('\n')); + + try { + execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 }); + } catch {} + + const result = fs.readFileSync(roadmapPath, 'utf-8'); + + assert.match( + result, + /Phase 2: Build[\s\S]*?\*\*Plans:\*\*\s*0\/2 plans complete/, + 'Phase 2 plan count must remain 0/2 (untouched)' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// regression: bug #2526 — phase complete warns about unregistered REQ-IDs +// (consolidated from tests/bug-2526-phase-complete-req-discovery.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-')); + planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ project_code: '' }) + ); + + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('emits warning for REQ-IDs in body but missing from Traceability table', () => { + const phasesDir = path.join(planningDir, 'phases', '01-foundation'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '', + '**Goal:** Build core', + '**Requirements:** REQ-001', + '**Plans:** 1 plans', + '', + 'Plans:', + '- [x] 01-1-PLAN.md', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Foundation | 0/1 | Pending | - |', + ].join('\n')); + + const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); + fs.writeFileSync(reqPath, [ + '# Requirements', + '', + '## Functional Requirements', + '', + '- [x] **REQ-001**: Core data model', + '- [ ] **REQ-002**: User authentication', + '- [ ] **REQ-003**: API endpoints', + '', + '## Traceability', + '', + '| REQ-ID | Phase | Status |', + '|--------|-------|--------|', + '| REQ-001 | 1 | Pending |', + ].join('\n')); + + let stdout = ''; + let stderr = ''; + try { + const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { + cwd: tmpDir, + timeout: 10000, + encoding: 'utf-8', + }); + stdout = result; + } catch (err) { + stdout = err.stdout || ''; + stderr = err.stderr || ''; + throw err; + } + + const combined = stdout + stderr; + assert.match(combined, /REQ-002/, 'output should mention REQ-002 as missing from Traceability table'); + assert.match(combined, /REQ-003/, 'output should mention REQ-003 as missing from Traceability table'); + }); + + test('no warning when all body REQ-IDs are present in Traceability table', () => { + const phasesDir = path.join(planningDir, 'phases', '01-foundation'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '', + '**Goal:** Build core', + '**Requirements:** REQ-001, REQ-002', + '**Plans:** 1 plans', + '', + 'Plans:', + '- [x] 01-1-PLAN.md', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Foundation | 0/1 | Pending | - |', + ].join('\n')); + + const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); + fs.writeFileSync(reqPath, [ + '# Requirements', + '', + '## Functional Requirements', + '', + '- [x] **REQ-001**: Core data model', + '- [x] **REQ-002**: User authentication', + '', + '## Traceability', + '', + '| REQ-ID | Phase | Status |', + '|--------|-------|--------|', + '| REQ-001 | 1 | Pending |', + '| REQ-002 | 1 | Pending |', + ].join('\n')); + + let stdout = ''; + let stderr = ''; + try { + const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { + cwd: tmpDir, + timeout: 10000, + encoding: 'utf-8', + }); + stdout = result; + } catch (err) { + stdout = err.stdout || ''; + stderr = err.stderr || ''; + throw err; + } + + const combined = stdout + stderr; + assert.doesNotMatch( + combined, + /unregistered|missing.*traceability|not in.*traceability/i, + 'no warning should appear when all REQ-IDs are in the table' + ); + }); + + test('warning includes all missing REQ-IDs, not just the first', () => { + const phasesDir = path.join(planningDir, 'phases', '01-foundation'); + fs.mkdirSync(phasesDir, { recursive: true }); + fs.writeFileSync( + path.join(phasesDir, '01-1-PLAN.md'), + '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' + ); + fs.writeFileSync( + path.join(phasesDir, '01-1-SUMMARY.md'), + '---\nstatus: complete\n---\n# Summary\nDone.' + ); + + const roadmapPath = path.join(planningDir, 'ROADMAP.md'); + fs.writeFileSync(roadmapPath, [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '', + '**Goal:** Build core', + '**Requirements:** REQ-001', + '**Plans:** 1 plans', + '', + 'Plans:', + '- [x] 01-1-PLAN.md', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 1. Foundation | 0/1 | Pending | - |', + ].join('\n')); + + const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); + fs.writeFileSync(reqPath, [ + '# Requirements', + '', + '- [x] **REQ-001**: Core data model', + '- [ ] **REQ-002**: User auth', + '- [ ] **REQ-003**: API', + '- [ ] **REQ-004**: Reports', + '', + '## Traceability', + '', + '| REQ-ID | Phase | Status |', + '|--------|-------|--------|', + '| REQ-001 | 1 | Pending |', + ].join('\n')); + + let stdout = ''; + let stderr = ''; + try { + const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { + cwd: tmpDir, + timeout: 10000, + encoding: 'utf-8', + }); + stdout = result; + } catch (err) { + stdout = err.stdout || ''; + stderr = err.stderr || ''; + throw err; + } + + const combined = stdout + stderr; + assert.match(combined, /REQ-002/, 'should warn about REQ-002'); + assert.match(combined, /REQ-003/, 'should warn about REQ-003'); + assert.match(combined, /REQ-004/, 'should warn about REQ-004'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// regression: bug #3287 — phase-dir prefix parity across creation paths +// (consolidated from tests/bug-3287-phase-dir-prefix-parity.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +// ─── shared fixture for bug-3287 ───────────────────────────────────────────── +function makeXRProject(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'XR' }), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap v1.0', + '', + '### Phase 1: Foundation', + '**Goal:** Setup project', + '**Plans:** 0 plans', + '', + '---', + '', + ].join('\n'), + ); +} + +describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('phase.add creates XR-02- when project_code is XR', () => { + makeXRProject(tmpDir); + + const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `phase.add failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_number, 2, 'phase number should be 2'); + + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + const dirs = fs.readdirSync(phasesDir); + const prefixedDirs = dirs.filter(d => d.startsWith('XR-')); + assert.ok( + prefixedDirs.length > 0, + `Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`, + ); + assert.ok( + dirs.some(d => d === 'XR-02-auth-service'), + `Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`, + ); + }); +}); + +describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { + makeXRProject(tmpDir); + + const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init phase-op failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); + assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); + + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + assert.ok( + output.expected_phase_dir.includes('XR-'), + `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, + ); + assert.ok( + output.expected_phase_dir.includes('foundation'), + `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, + ); + }); + + test('expected_phase_dir is null when no project_code is set', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', + ); + + const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init phase-op failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_dir, null); + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + assert.ok( + !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), + `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, + ); + }); +}); + +describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { + makeXRProject(tmpDir); + + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); + assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); + + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + assert.ok( + output.expected_phase_dir.includes('XR-'), + `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, + ); + assert.ok( + output.expected_phase_dir.includes('foundation'), + `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, + ); + }); + + test('expected_phase_dir omits prefix when project_code is not set', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', + ); + + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_dir, null); + assert.ok( + typeof output.expected_phase_dir === 'string', + `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, + ); + assert.ok( + !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), + `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// regression: bug #3298 — phase-dir prefix drift in workflows +// (consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +{ + const PMG_WF = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-milestone-gaps.md'); + const IMPORT_WF = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'import.md'); + const BACKLOG_WF = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'add-backlog.md'); + + function readWorkflow(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch (err) { + throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`); + } + } + + function containsBareTemplateMkdir(content) { + return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content); + } + + function containsBareShellVarMkdir(content) { + return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content) + || /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content); + } + + describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => { + test('workflow file exists', () => { + assert.ok(fs.existsSync(PMG_WF), `plan-milestone-gaps.md must exist at ${PMG_WF}`); + }); + + test('step 8 must not use bare {NN}-{name} mkdir pattern', () => { + const content = readWorkflow(PMG_WF); + assert.ok( + !containsBareTemplateMkdir(content), + 'plan-milestone-gaps.md must not contain bare mkdir .planning/phases/{NN}-{name} pattern — use phase.add or expected_phase_dir', + ); + }); + + test('step 8 must use expected_phase_dir or phase.add for directory creation', () => { + const content = readWorkflow(PMG_WF); + const usesExpectedPhaseDir = content.includes('expected_phase_dir'); + const usesPhaseAdd = content.includes('phase.add'); + assert.ok( + usesExpectedPhaseDir || usesPhaseAdd, + 'plan-milestone-gaps.md must use expected_phase_dir (from init.phase-op) or phase.add to create phase directories with project_code prefix', + ); + }); + }); + + describe('bug-3298 — import.md must not construct bare {NN}-{slug} phase dirs', () => { + test('workflow file exists', () => { + assert.ok(fs.existsSync(IMPORT_WF), `import.md must exist at ${IMPORT_WF}`); + }); + + test('plan_convert step must not use bare {NN}-{slug} mkdir pattern', () => { + const content = readWorkflow(IMPORT_WF); + assert.ok( + !containsBareTemplateMkdir(content), + 'import.md must not contain bare mkdir .planning/phases/{NN}-{slug} pattern — use expected_phase_dir from init.phase-op', + ); + }); + + test('plan_convert step must use expected_phase_dir for directory creation', () => { + const content = readWorkflow(IMPORT_WF); + assert.ok( + content.includes('expected_phase_dir'), + 'import.md must use expected_phase_dir (from init.phase-op) to create phase directory with project_code prefix', + ); + }); + + test('plan_convert step must call init.phase-op to resolve the prefixed dir', () => { + const content = readWorkflow(IMPORT_WF); + assert.ok( + content.includes('init.phase-op') || content.includes('init phase-op'), + 'import.md must call gsd-sdk query init.phase-op to get expected_phase_dir with project_code prefix', + ); + }); + }); + + describe('bug-3298 — add-backlog.md must apply project_code prefix when creating 999.x dirs', () => { + test('workflow file exists', () => { + assert.ok(fs.existsSync(BACKLOG_WF), `add-backlog.md must exist at ${BACKLOG_WF}`); + }); + + test('step 4 must not use bare ${NEXT}-${SLUG} mkdir without project_code prefix', () => { + const content = readWorkflow(BACKLOG_WF); + assert.ok( + !containsBareShellVarMkdir(content), + 'add-backlog.md must not create .planning/phases/${NEXT}-${SLUG} without a project_code prefix variable — apply ${PREFIX} (or equivalent) before ${NEXT}', + ); + }); + + test('step 4 must reference project_code or a prefix variable before the phase number', () => { + const content = readWorkflow(BACKLOG_WF); + const hasProjectCodeRef = content.includes('project_code') || content.includes('PROJECT_CODE'); + const hasPrefixVar = content.includes('${PREFIX}') || content.includes('${PHASE_PREFIX}') || content.includes('${CODE}'); + assert.ok( + hasProjectCodeRef || hasPrefixVar, + 'add-backlog.md must read project_code (or use a PREFIX variable) to apply the project_code prefix to the 999.x phase directory name', + ); + }); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// regression: bug #3517 — phase.complete leaves STATE.md with stale fields +// (consolidated from tests/bug-3517-phase-complete-state-md-staleness.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +{ + function runSdkQuery(args, cwd) { + try { + const result = execFileSync(process.execPath, [SDK_CLI, 'query', ...args], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + const parsed = JSON.parse(result.trim()); + return { success: true, data: parsed }; + } catch (err) { + const stderr = err.stderr?.toString().trim() || ''; + const stdout = err.stdout?.toString().trim() || ''; + try { + const parsed = JSON.parse(stdout); + return { success: true, data: parsed }; + } catch { /* not JSON */ } + return { success: false, error: stderr || err.message }; + } + } + + function setupPhase3517Project(tmpDir) { + const planningDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planningDir, 'phases'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.mkdirSync(phasesDir, { recursive: true }); + + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ project_code: 'TEST' }), + ); + + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v3.0', + '', + '| Phase | Plans | Status | Completed |', + '|-------|-------|--------|-----------|', + '| 4. | 3/3 | Complete | 2026-04-01 |', + '| 5. | 7/7 | In Progress | |', + '| 6. | 0/5 | Not Started | |', + '', + '- [x] Phase 4: Foundation (completed 2026-04-01)', + '- [ ] Phase 5: Core API', + '- [ ] Phase 6: Integration', + '', + '### Phase 4: Foundation', + '', + '**Goal:** Foundation work', + '**Plans:** 3/3 plans complete', + '', + '### Phase 5: Core API', + '', + '**Goal:** Build core API layer', + '**Plans:** 7 plans', + '', + 'Plans:', + '- [ ] 05-01 plan', + '- [ ] 05-02 plan', + '- [ ] 05-03 plan', + '- [ ] 05-04 plan', + '- [ ] 05-05 plan', + '- [ ] 05-06 plan', + '- [ ] 05-07 plan', + '', + '### Phase 6: Integration', + '', + '**Goal:** Integration work', + '**Plans:** 5 plans', + '', + '---', + '*Last updated: 2026-05-14*', + ].join('\n'); + + fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); + + const state = [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v3.0', + 'milestone_name: Core Platform', + 'status: executing', + 'stopped_at: Completed 05-03-PLAN.md', + 'last_updated: 2026-05-10T08:00:00.000Z', + 'progress:', + ' total_phases: 3', + ' completed_phases: 1', + ' total_plans: 15', + ' completed_plans: 6', + ' percent: 33', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + '**Current focus:** Phase 5 — Core API', + 'Phase: 5 of 3 (Core API) — EXECUTING', + 'Plan: 7 of 7', + 'Status: Executing Phase 5', + 'Last activity: 2026-05-10', + '', + '## Progress', + '', + 'Progress: [████████████░░░░] 40% (1/3 phases complete before Phase 5 closeout)', + '', + '## Performance Metrics', + '', + '**Velocity:**', + '', + '- Total plans completed: 6', + '- Average duration: 2h', + '- Total execution time: 14 hours', + '- Window: 2026-04-01 to 2026-05-10', + '', + '**By Phase:**', + '', + '| Phase | Plans | Total | Avg/Plan |', + '|-------|-------|-------|----------|', + '| 4 | 3 | - | - |', + '', + '## Session Continuity', + '', + 'Last session: 2026-05-10T08:00:00.000Z', + 'Stopped at: Completed 05-07-PLAN.md', + ].join('\n'); + + fs.writeFileSync(path.join(planningDir, 'STATE.md'), state); + + const phase5Dir = path.join(phasesDir, '05-core-api'); + fs.mkdirSync(phase5Dir, { recursive: true }); + for (let i = 1; i <= 7; i++) { + const padded = String(i).padStart(2, '0'); + fs.writeFileSync(path.join(phase5Dir, `05-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); + fs.writeFileSync(path.join(phase5Dir, `05-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); + } + + const phase4Dir = path.join(phasesDir, '04-foundation'); + fs.mkdirSync(phase4Dir, { recursive: true }); + for (let i = 1; i <= 3; i++) { + const padded = String(i).padStart(2, '0'); + fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); + fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); + } + + const phase6Dir = path.join(phasesDir, '06-integration'); + fs.mkdirSync(phase6Dir, { recursive: true }); + + return { planningDir, phase5Dir }; + } + + describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3517-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('completed_phases is derived from ROADMAP, not blindly incremented (idempotency)', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r1 = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r1.success, `first call failed: ${r1.error}`); + + const stateAfter1 = fs.readFileSync(statePath, 'utf8'); + const match1 = stateAfter1.match(/completed_phases:\s*(\d+)/); + assert.ok(match1, 'completed_phases not found in frontmatter after first call'); + assert.equal( + Number(match1[1]), + 2, + `After first call: completed_phases should be 2 (derived from ROADMAP: phases 4 and 5 complete), got ${match1[1]}`, + ); + + const r2 = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r2.success, `second call failed: ${r2.error}`); + + const stateAfter2 = fs.readFileSync(statePath, 'utf8'); + const match2 = stateAfter2.match(/completed_phases:\s*(\d+)/); + assert.ok(match2, 'completed_phases not found in frontmatter after second call'); + assert.equal( + Number(match2[1]), + 2, + `After second call (same phase): completed_phases must remain 2 (idempotent), got ${match2[1]}`, + ); + }); + + test('frontmatter stopped_at is updated after phase.complete', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + + const state = fs.readFileSync(statePath, 'utf8'); + const stoppedMatch = state.match(/stopped_at:\s*(.+)/); + assert.ok(stoppedMatch, 'stopped_at not found in frontmatter'); + assert.ok( + !stoppedMatch[1].includes('05-03-PLAN.md'), + `stopped_at should not still say "Completed 05-03-PLAN.md" — got: ${stoppedMatch[1]}`, + ); + assert.ok( + stoppedMatch[1].toLowerCase().includes('phase 5') || + stoppedMatch[1].toLowerCase().includes('complete'), + `stopped_at should reference phase 5 completion, got: ${stoppedMatch[1]}`, + ); + }); + + test('frontmatter last_updated is refreshed to today after phase.complete', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + + const state = fs.readFileSync(statePath, 'utf8'); + const lastUpdatedMatch = state.match(/last_updated:\s*(.+)/); + assert.ok(lastUpdatedMatch, 'last_updated not found in frontmatter'); + assert.notEqual( + lastUpdatedMatch[1].trim(), + '2026-05-10T08:00:00.000Z', + `last_updated must be refreshed, but it is still the stale value: ${lastUpdatedMatch[1]}`, + ); + const updatedAt = new Date(lastUpdatedMatch[1].trim()); + const now = new Date(); + const diffMs = Math.abs(now - updatedAt); + assert.ok( + diffMs < 60_000, + `last_updated should be approximately now (within 60s), got: ${lastUpdatedMatch[1]} (diff: ${diffMs}ms)`, + ); + }); + + test('frontmatter total_plans is updated from ROADMAP plan counts after phase.complete', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + + const state = fs.readFileSync(statePath, 'utf8'); + const match = state.match(/total_plans:\s*(\d+)/); + assert.ok(match, 'total_plans not found in frontmatter'); + const totalPlans = Number(match[1]); + assert.ok(Number.isFinite(totalPlans) && totalPlans > 0, `total_plans must be a positive number, got: ${match[1]}`); + }); + + test('frontmatter completed_plans is updated from SUMMARY file count after phase.complete', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + + const state = fs.readFileSync(statePath, 'utf8'); + const match = state.match(/completed_plans:\s*(\d+)/); + assert.ok(match, 'completed_plans not found in frontmatter'); + const completedPlans = Number(match[1]); + assert.equal( + completedPlans, + 10, + `completed_plans should be 10 (3 phase-4 summaries + 7 phase-5 summaries), got: ${completedPlans}`, + ); + }); + + test('frontmatter percent is recomputed from fresh derived counts', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + + const state = fs.readFileSync(statePath, 'utf8'); + const match = state.match(/percent:\s*(\d+)/); + assert.ok(match, 'percent not found in frontmatter'); + assert.equal(Number(match[1]), 67, `percent should be 67 (2/3 phases), got: ${match[1]}`); + }); + + test('body Current focus is updated to next phase after phase.complete', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + + const state = fs.readFileSync(statePath, 'utf8'); + assert.ok( + !state.includes('Current focus:** Phase 5') && !state.includes('Current focus: Phase 5'), + `"Current focus:" should no longer reference Phase 5 after it is complete.\nState:\n${state}`, + ); + }); + + test('body By Phase table row for completed phase shows correct plan count', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + + const state = fs.readFileSync(statePath, 'utf8'); + assert.match( + state, + /\|\s*5\s*\|\s*7\s*\|/, + `By Phase table should have a row for phase 5 with 7 summaries.\nState:\n${state}`, + ); + }); + + test('full consistency check: all STATE.md fields are coherent after phase.complete', () => { + setupPhase3517Project(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + const r = runSdkQuery(['phase.complete', '5'], tmpDir); + assert.ok(r.success, `call failed: ${r.error}`); + assert.equal(r.data?.state_updated, true, 'state_updated must be true'); + + const state = fs.readFileSync(statePath, 'utf8'); + + assert.match(state, /completed_phases:\s*2/, 'completed_phases must be 2 (4 and 5 complete)'); + assert.match(state, /percent:\s*67/, 'percent must be 67%'); + assert.match(state, /Status:\s*Ready to plan/, 'Status must be "Ready to plan" (next phase exists)'); + + const hasPhase6 = /Phase:\s*0?6/.test(state) || /current_phase:\s*0?6/.test(state); + assert.ok(hasPhase6, `STATE.md must reference Phase 6 as current after completing Phase 5.\nState:\n${state}`); + }); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// regression: bug #3601 — phase remove preserves peer-depth decimal sections +// (consolidated from tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +{ + process.env.GSD_TEST_MODE = '1'; + + function writeRoadmapForRemove(tmpDir, body) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); + } + function writeStateForRemove(tmpDir, version) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `---\nmilestone: ${version}\n---\n`, + ); + } + function ensurePhaseDir(tmpDir, name) { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true }); + } + function getPhase(tmpDir, phaseNum) { + const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir); + if (!r.success) return { found: false, error: r.error }; + return JSON.parse(r.output); + } + + describe('bug #3601: phase remove preserves peer-depth decimal sections', () => { + let tmpDir; + beforeEach(() => { + tmpDir = createTempProject('bug-3601-'); + }); + afterEach(() => { + cleanup(tmpDir); + }); + + test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => { + writeStateForRemove(tmpDir, 'v1.0.0'); + writeRoadmapForRemove( + tmpDir, + [ + '# Roadmap', + '', + '## Current Milestone: v1.0.0 - Test', + '', + '### Phase 2: Parent', + '**Goal:** RemoveMeGoal', + '', + '### Phase 2.1: Follow-up', + '**Goal:** PreserveDecimalGoal', + '', + '### Phase 3: Trailing', + '**Goal:** PreserveTrailingGoal', + '', + ].join('\n'), + ); + ensurePhaseDir(tmpDir, '02-parent'); + ensurePhaseDir(tmpDir, '02.1-follow-up'); + ensurePhaseDir(tmpDir, '03-trailing'); + + const r = runGsdTools(['phase', 'remove', '2'], tmpDir); + assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); + + const decimal = getPhase(tmpDir, '2.1'); + assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2'); + assert.strictEqual(decimal.phase_name, 'Follow-up'); + assert.strictEqual(decimal.goal, 'PreserveDecimalGoal'); + + const renumbered = getPhase(tmpDir, '2'); + assert.strictEqual(renumbered.found, true); + assert.strictEqual(renumbered.phase_name, 'Trailing'); + assert.strictEqual( + renumbered.goal, + 'PreserveTrailingGoal', + 'Phase 3 → Phase 2 renumber did not carry the right section content', + ); + + const parentLookup = getPhase(tmpDir, '3'); + assert.notStrictEqual( + parentLookup.goal, + 'RemoveMeGoal', + 'removed parent goal reappeared under a phase header', + ); + }); + + test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => { + writeStateForRemove(tmpDir, 'v1.0.0'); + writeRoadmapForRemove( + tmpDir, + [ + '# Roadmap', + '', + '## Current Milestone: v1.0.0 - Test', + '', + '### Phase 5: Parent', + '**Goal:** RemoveParent', + '', + '### Phase 5.1: First child', + '**Goal:** ChildAGoal', + '', + '### Phase 5.2: Second child', + '**Goal:** ChildBGoal', + '', + '### Phase 6: Tail', + '**Goal:** TailGoal', + '', + ].join('\n'), + ); + ensurePhaseDir(tmpDir, '05-parent'); + ensurePhaseDir(tmpDir, '05.1-first-child'); + ensurePhaseDir(tmpDir, '05.2-second-child'); + ensurePhaseDir(tmpDir, '06-tail'); + + const r = runGsdTools(['phase', 'remove', '5'], tmpDir); + assert.ok(r.success); + + const decimalA = getPhase(tmpDir, '5.1'); + assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted'); + assert.strictEqual(decimalA.goal, 'ChildAGoal'); + + const decimalB = getPhase(tmpDir, '5.2'); + assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted'); + assert.strictEqual(decimalB.goal, 'ChildBGoal'); + + const tail = getPhase(tmpDir, '5'); + assert.strictEqual(tail.found, true); + assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired'); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // regression: bug #3602 — phase remove renumbers slugged plan references + // (consolidated from tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs) + // ───────────────────────────────────────────────────────────────────────────── + + function ensurePlanFile(tmpDir, phaseDirName, planName) { + const p = path.join(tmpDir, '.planning', 'phases', phaseDirName, planName); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, '# Plan\n'); + } + + describe('bug #3602: phase remove renumbers slugged plan references in ROADMAP', () => { + let tmpDir; + beforeEach(() => { + tmpDir = createTempProject('bug-3602-'); + }); + afterEach(() => { + cleanup(tmpDir); + }); + + test('slugged PLAN reference (07-01-cherry-pick-foundation-PLAN.md) is renumbered to 06-01-…', () => { + writeStateForRemove(tmpDir, 'v1.0.0'); + writeRoadmapForRemove( + tmpDir, + [ + '# Roadmap', + '', + '## Current Milestone: v1.0.0', + '', + '### Phase 6: Old Work', + '**Goal:** RemoveThisGoal', + '', + '### Phase 7: New Work', + '**Goal:** Plans: 07-01-cherry-pick-foundation-PLAN.md and 07-02-finish-it-SUMMARY.md', + '', + ].join('\n'), + ); + ensurePhaseDir(tmpDir, '06-old'); + ensurePhaseDir(tmpDir, '07-new'); + ensurePlanFile(tmpDir, '07-new', '07-01-cherry-pick-foundation-PLAN.md'); + ensurePlanFile(tmpDir, '07-new', '07-02-finish-it-SUMMARY.md'); + + const r = runGsdTools(['phase', 'remove', '6'], tmpDir); + assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); + + const phase6 = getPhase(tmpDir, '6'); + assert.strictEqual(phase6.found, true); + assert.strictEqual(phase6.phase_name, 'New Work'); + assert.ok( + phase6.goal.includes('06-01-cherry-pick-foundation-PLAN.md'), + `Plans reference for slugged PLAN was not renumbered. Goal: ${phase6.goal}`, + ); + assert.ok( + phase6.goal.includes('06-02-finish-it-SUMMARY.md'), + `Plans reference for slugged SUMMARY was not renumbered. Goal: ${phase6.goal}`, + ); + assert.ok( + !phase6.goal.includes('07-01'), + `stale 07-01 prefix remains in ROADMAP. Goal: ${phase6.goal}`, + ); + assert.ok( + !phase6.goal.includes('07-02'), + `stale 07-02 prefix remains in ROADMAP. Goal: ${phase6.goal}`, + ); + }); + + test('compact PLAN/SUMMARY reference (07-01-PLAN.md) still renumbers (#3601 contract preserved)', () => { + writeStateForRemove(tmpDir, 'v1.0.0'); + writeRoadmapForRemove( + tmpDir, + [ + '# Roadmap', + '', + '## Current Milestone: v1.0.0', + '', + '### Phase 6: Old', + '**Goal:** RemoveGoal', + '', + '### Phase 7: New', + '**Goal:** Plans: 07-01-PLAN.md and 07-02-SUMMARY.md', + '', + ].join('\n'), + ); + ensurePhaseDir(tmpDir, '06-old'); + ensurePhaseDir(tmpDir, '07-new'); + ensurePlanFile(tmpDir, '07-new', '07-01-PLAN.md'); + ensurePlanFile(tmpDir, '07-new', '07-02-SUMMARY.md'); + + const r = runGsdTools(['phase', 'remove', '6'], tmpDir); + assert.ok(r.success); + + const phase6 = getPhase(tmpDir, '6'); + assert.strictEqual(phase6.found, true); + assert.ok(phase6.goal.includes('06-01-PLAN.md')); + assert.ok(phase6.goal.includes('06-02-SUMMARY.md')); + assert.ok(!phase6.goal.includes('07-01-PLAN.md')); + assert.ok(!phase6.goal.includes('07-02-SUMMARY.md')); + }); + + test('does NOT renumber values that look like phase-plan tokens but are not (e.g. 2026-01-01 dates)', () => { + writeStateForRemove(tmpDir, 'v1.0.0'); + writeRoadmapForRemove( + tmpDir, + [ + '# Roadmap', + '', + '## Current Milestone: v1.0.0', + '', + '### Phase 6: Old', + '**Goal:** RemoveGoal', + '', + '### Phase 7: Date safety', + '**Goal:** Created 2026-01-01 and tagged v1-2-3 — must not renumber', + '', + ].join('\n'), + ); + ensurePhaseDir(tmpDir, '06-old'); + ensurePhaseDir(tmpDir, '07-new'); + + const r = runGsdTools(['phase', 'remove', '6'], tmpDir); + assert.ok(r.success); + + const phase6 = getPhase(tmpDir, '6'); + assert.strictEqual(phase6.found, true); + assert.ok( + phase6.goal.includes('2026-01-01'), + `ISO date 2026-01-01 was wrongly modified. Goal: ${phase6.goal}`, + ); + assert.ok( + phase6.goal.includes('v1-2-3'), + `version-tag v1-2-3 was wrongly modified. Goal: ${phase6.goal}`, + ); + }); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// feature: phase complete auto-prune (#2087) +// (consolidated from tests/phase-complete-auto-prune.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +{ + function writeConfigForAutoPrune(tmpDir, config) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2)); + } + + function writeStateMdForAutoPrune(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content); + } + + function readStateMdForAutoPrune(tmpDir) { + return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + } + + function writeRoadmapForAutoPrune(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); + } + + function setupPhaseForAutoPrune(tmpDir, phaseNum, planCount) { + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + const phaseDir = path.join(phasesDir, `${String(phaseNum).padStart(2, '0')}-test-phase`); + fs.mkdirSync(phaseDir, { recursive: true }); + + for (let i = 1; i <= planCount; i++) { + const planId = `${String(phaseNum).padStart(2, '0')}-${String(i).padStart(2, '0')}`; + fs.writeFileSync(path.join(phaseDir, `${planId}-PLAN.md`), `# Plan ${planId}\n`); + fs.writeFileSync(path.join(phaseDir, `${planId}-SUMMARY.md`), `# Summary ${planId}\n`); + } + } + + describe('phase complete auto-prune (#2087)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('prunes STATE.md automatically when auto_prune_state is true', () => { + writeConfigForAutoPrune(tmpDir, { + workflow: { auto_prune_state: true }, + }); + + writeStateMdForAutoPrune(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 6', + '**Status:** Executing', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision from phase 1', + '- [Phase 2]: Old decision from phase 2', + '- [Phase 5]: Recent decision', + '- [Phase 6]: Current decision', + '', + ].join('\n')); + + writeRoadmapForAutoPrune(tmpDir, [ + '# Roadmap', + '', + '## Phase 6: Test Phase', + '', + '**Plans:** 0/2', + '', + ].join('\n')); + + setupPhaseForAutoPrune(tmpDir, 6, 2); + + const result = runGsdTools('phase complete 6', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMdForAutoPrune(tmpDir); + assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/); + assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/); + assert.match(newState, /\[Phase 5\]: Recent decision/); + assert.match(newState, /\[Phase 6\]: Current decision/); + }); + + test('does NOT prune when auto_prune_state is false (default)', () => { + writeConfigForAutoPrune(tmpDir, { + workflow: { auto_prune_state: false }, + }); + + writeStateMdForAutoPrune(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 6', + '**Status:** Executing', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision from phase 1', + '- [Phase 5]: Recent decision', + '- [Phase 6]: Current decision', + '', + ].join('\n')); + + writeRoadmapForAutoPrune(tmpDir, [ + '# Roadmap', + '', + '## Phase 6: Test Phase', + '', + '**Plans:** 0/2', + '', + ].join('\n')); + + setupPhaseForAutoPrune(tmpDir, 6, 2); + + const result = runGsdTools('phase complete 6', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMdForAutoPrune(tmpDir); + assert.match(newState, /\[Phase 1\]: Old decision/); + }); + + test('does NOT prune when auto_prune_state is absent from config', () => { + writeConfigForAutoPrune(tmpDir, { + workflow: {}, + }); + + writeStateMdForAutoPrune(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 6', + '**Status:** Executing', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision from phase 1', + '- [Phase 6]: Current decision', + '', + ].join('\n')); + + writeRoadmapForAutoPrune(tmpDir, [ + '# Roadmap', + '', + '## Phase 6: Test Phase', + '', + '**Plans:** 0/2', + '', + ].join('\n')); + + setupPhaseForAutoPrune(tmpDir, 6, 2); + + const result = runGsdTools('phase complete 6', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMdForAutoPrune(tmpDir); + assert.match(newState, /\[Phase 1\]: Old decision/); + }); + }); +}