From fa1ffb4824dd7cecb427984eeb520922579bf1dd Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 20 Jun 2026 13:37:22 -0400 Subject: [PATCH] fix(#1437): add phase.list-plans to gsd-tools (#1485) * fix(#1437): add phase.list-plans to gsd-tools Register phase.list-plans in PHASE_COMMAND_ALIASES, implement cmdPhaseListPlans in src/phase.cts (uses findPhaseInternal + scanPhasePlans to return plan_count/has_plans/plans/phase_dir), and wire the handler in phase-command-router. Previously every call produced "Unknown phase subcommand". Co-Authored-By: Claude Sonnet 4.6 * fix(#1437): register new test file in lint-test-file-count allowlist Co-Authored-By: Claude Sonnet 4.6 * fix(#1437): rename test to fix-NNN convention; update file-count allowlist Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/sturdy-wasps-swim.md | 5 + scripts/lint-test-file-count.allowlist.json | 1 + src/command-aliases.cts | 8 ++ src/phase-command-router.cts | 6 + src/phase.cts | 35 ++++++ tests/fix-1437-phase-list-plans.test.cjs | 125 ++++++++++++++++++++ tests/phase-command-router.test.cjs | 35 +++--- 7 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 .changeset/sturdy-wasps-swim.md create mode 100644 tests/fix-1437-phase-list-plans.test.cjs diff --git a/.changeset/sturdy-wasps-swim.md b/.changeset/sturdy-wasps-swim.md new file mode 100644 index 000000000..f499355f6 --- /dev/null +++ b/.changeset/sturdy-wasps-swim.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1437 +--- +add phase.list-plans to gsd-tools — the command was referenced in agents/gsd-plan-checker.md but was missing from the router, causing 'Unknown phase subcommand' on every invocation diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 58682cd66..c7fbbdd34 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -60,6 +60,7 @@ }, "phase": { "files": [ + "fix-1437-phase-list-plans.test.cjs", "bug-214-phase-researcher-write-truncation-contract.test.cjs", "phase-dependency-levels.test.cjs", "phase.test.cjs" diff --git a/src/command-aliases.cts b/src/command-aliases.cts index 506b9cb64..9269ce019 100644 --- a/src/command-aliases.cts +++ b/src/command-aliases.cts @@ -458,6 +458,14 @@ export const PHASE_COMMAND_ALIASES: CommandAlias[] = [ ], "subcommand": "scaffold", "mutation": true + }, + { + "canonical": "phase.list-plans", + "aliases": [ + "phase list-plans" + ], + "subcommand": "list-plans", + "mutation": false } ]; diff --git a/src/phase-command-router.cts b/src/phase-command-router.cts index f9eb45eb0..86502ec8c 100644 --- a/src/phase-command-router.cts +++ b/src/phase-command-router.cts @@ -33,6 +33,7 @@ interface PhaseHandlers { cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void; cmdPhaseComplete: (cwd: string, phaseNum: string | undefined, raw: boolean) => void; cmdPhaseUatPassed: (cwd: string, phaseNum: string | undefined, raw: boolean, opts?: { policy?: { requireVerification?: boolean } }) => void; + cmdPhaseListPlans: (cwd: string, phaseNum: string | undefined, raw: boolean) => void; } interface RoutePhaseCommandOptions { @@ -182,6 +183,11 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification } }); return { ok: true as const, data: null }; }, + // #1437 — list plan files for a phase + 'list-plans': (_ctx: Record): { ok: true; data: null } => { + phase.cmdPhaseListPlans(cwd, args[2], raw); + return { ok: true as const, data: null }; + }, }, }; diff --git a/src/phase.cts b/src/phase.cts index 832f0444e..857be90d5 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -1825,6 +1825,40 @@ function cmdPhaseUatPassed( output({ phase: phaseNum, ...report }, raw); } +// #1437 — phase.list-plans: list plan files for a given phase number. +// Returns the full scan result from scanPhasePlans so callers can read plan +// paths without re-discovering the phase directory themselves. +// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module +import planScanMod = require('./plan-scan.cjs'); +const { scanPhasePlans } = planScanMod; + +function cmdPhaseListPlans(cwd: string, phaseNum: string | undefined, raw: boolean): void { + if (!phaseNum) { + error('phase number required for phase list-plans'); + } + + const phaseInfo = findPhaseInternal(cwd, phaseNum!); + if (!phaseInfo) { + output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw); + return; + } + + const phaseDir = path.join(cwd, (phaseInfo as unknown as Record)['directory'] as string); + const scan = scanPhasePlans(phaseDir); + const phaseRel = (phaseInfo as unknown as Record)['directory'] as string; + + // Build absolute-usable relative paths for each plan file. + const plans = scan.planFiles.map((f: string) => toPosixPath(path.join(phaseRel, f))); + + output({ + phase: phaseNum, + phase_dir: phaseRel, + plan_count: scan.planCount, + has_plans: scan.planCount > 0, + plans, + }, raw); +} + export = { cmdPhasesList, cmdPhaseNextDecimal, @@ -1837,5 +1871,6 @@ export = { cmdPhaseRemove, cmdPhaseComplete, cmdPhaseUatPassed, + cmdPhaseListPlans, computeDependencyLevels, }; diff --git a/tests/fix-1437-phase-list-plans.test.cjs b/tests/fix-1437-phase-list-plans.test.cjs new file mode 100644 index 000000000..f819606c6 --- /dev/null +++ b/tests/fix-1437-phase-list-plans.test.cjs @@ -0,0 +1,125 @@ +'use strict'; + +/** + * Regression tests for `gsd-tools query phase.list-plans ` (#1437). + * + * Prior to this fix, `phase.list-plans` was not registered in the + * phase-command-router, so any invocation produced: + * "Error: Unknown phase subcommand. Available: uat-passed, next-decimal, ..." + * + * These tests exercise the full dispatch path: + * gsd-tools → phase-command-router → phase.cmdPhaseListPlans + */ + +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'); + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +function setupProject(phaseSlug = '01-feature') { + const tmpDir = createTempProject(); + // Minimal ROADMAP so findPhaseInternal can resolve the phase directory + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '- [ ] Phase 1: Feature', + '', + '### Phase 1: Feature', + '**Goal:** Build feature', + '**Plans:** 1 plans', + '', + ].join('\n'), + ); + const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug); + fs.mkdirSync(phaseDir, { recursive: true }); + return { tmpDir, phaseDir }; +} + +function touch(dir, ...files) { + for (const f of files) { + fs.writeFileSync(path.join(dir, f), ''); + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +let tmpDir; +let phaseDir; + +beforeEach(() => { + const proj = setupProject('01-feature'); + tmpDir = proj.tmpDir; + phaseDir = proj.phaseDir; +}); + +afterEach(() => { + cleanup(tmpDir); +}); + +describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => { + test('command no longer returns Unknown phase subcommand error', () => { + touch(phaseDir, '01-01-PLAN.md'); + const result = runGsdTools(['query', 'phase.list-plans', '1'], tmpDir); + // Previously this would fail with "Unknown phase subcommand" + assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`); + assert.ok(!result.error || !result.error.includes('Unknown phase subcommand'), + `got unexpected error: ${result.error}`); + }); + + test('returns JSON with plan_count and plans array when plans exist', () => { + touch(phaseDir, '01-01-PLAN.md', '01-02-PLAN.md'); + const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`); + const data = JSON.parse(result.output); + assert.equal(data.plan_count, 2, 'plan_count should be 2'); + assert.equal(data.has_plans, true, 'has_plans should be true'); + assert.ok(Array.isArray(data.plans), 'plans should be an array'); + assert.equal(data.plans.length, 2, 'plans array should have 2 entries'); + }); + + test('returns plan_count 0 and empty plans array when phase has no plan files', () => { + // Phase directory exists but has no *-PLAN.md files + touch(phaseDir, 'CONTEXT.md'); + const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`); + const data = JSON.parse(result.output); + assert.equal(data.plan_count, 0); + assert.equal(data.has_plans, false); + assert.deepEqual(data.plans, []); + }); + + test('returns has_plans false when phase number is not found', () => { + // Phase 99 does not exist in the fixture + const result = runGsdTools(['query', 'phase.list-plans', '99', '--raw'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`); + const data = JSON.parse(result.output); + assert.equal(data.has_plans, false); + assert.equal(data.plan_count, 0); + }); + + test('plan paths are relative to project root and posix-style', () => { + touch(phaseDir, '01-01-PLAN.md'); + const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`); + const data = JSON.parse(result.output); + assert.equal(data.plans.length, 1); + // Paths must be forward-slash separated (posix) and relative (not absolute) + const planPath = data.plans[0]; + assert.ok(!path.isAbsolute(planPath), `expected relative path, got: ${planPath}`); + assert.ok(!planPath.includes('\\'), `expected posix path, got: ${planPath}`); + assert.ok(planPath.includes('01-01-PLAN.md'), `expected plan filename in path: ${planPath}`); + }); + + test('dotted form phase.list-plans (without query prefix) also works', () => { + touch(phaseDir, '01-01-PLAN.md'); + const result = runGsdTools(['phase.list-plans', '1', '--raw'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`); + const data = JSON.parse(result.output); + assert.equal(data.plan_count, 1); + }); +}); diff --git a/tests/phase-command-router.test.cjs b/tests/phase-command-router.test.cjs index ff320d896..b7b84332c 100644 --- a/tests/phase-command-router.test.cjs +++ b/tests/phase-command-router.test.cjs @@ -45,6 +45,7 @@ function makePhase(overrides = {}) { cmdPhaseInsert: () => {}, cmdPhaseRemove: () => {}, cmdPhaseComplete: () => {}, + cmdPhaseListPlans: () => {}, ...overrides, }; } @@ -280,26 +281,27 @@ describe('phase-command-router — result translation (error path)', () => { assert.ok(msg !== null); assert.ok(msg.includes('exactly one phase number')); }); + + // #1437 — phase.list-plans routing + test('routes phase list-plans: passes cwd, phaseNum, raw to handler', () => { + const calls = []; + const phase = makePhase({ + cmdPhaseListPlans: (cwd, phaseNum, raw) => calls.push({ cwd, phaseNum, raw }), + }); + + routePhaseCommand({ phase, args: ['phase', 'list-plans', '03'], cwd: '/proj', raw: false, error: (m) => { throw new Error(m); } }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].cwd, '/proj'); + assert.equal(calls[0].phaseNum, '03'); + assert.equal(calls[0].raw, false); + }); }); // ─── 3. Unsupported subcommands ──────────────────────────────────────────────── describe('phase-command-router — unsupported subcommands', () => { - test('phase list-plans resolves as unknown subcommand', () => { - let msg = null; - routePhaseCommand({ - phase: makePhase(), - args: ['phase', 'list-plans'], - cwd: '/p', - raw: false, - error: (m) => { msg = m; }, - }); - - assert.ok(msg !== null); - assert.ok(msg.includes('Unknown phase subcommand')); - assert.ok(msg.includes('Available:'), `expected "Available:" in: ${msg}`); - }); - + // #1437: phase list-plans is now a supported subcommand — routing test in § 1. test('phase list-artifacts resolves as unknown subcommand', () => { let msg = null; routePhaseCommand({ @@ -363,7 +365,8 @@ describe('phase-command-router — unknown subcommand', () => { assert.ok(msg.includes('add'), `expected add in available list: ${msg}`); assert.ok(msg.includes('complete'), `expected complete in available list: ${msg}`); - assert.ok(!msg.includes('list-plans'), `list-plans must not appear in available list: ${msg}`); + // #1437: list-plans is now a supported command and appears in the available list + assert.ok(msg.includes('list-plans'), `list-plans must appear in available list: ${msg}`); }); });