* 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 <noreply@anthropic.com> * fix(#1437): register new test file in lint-test-file-count allowlist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#1437): rename test to fix-NNN convention; update file-count allowlist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/sturdy-wasps-swim.md
Normal file
5
.changeset/sturdy-wasps-swim.md
Normal file
@@ -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
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
},
|
},
|
||||||
"phase": {
|
"phase": {
|
||||||
"files": [
|
"files": [
|
||||||
|
"fix-1437-phase-list-plans.test.cjs",
|
||||||
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
||||||
"phase-dependency-levels.test.cjs",
|
"phase-dependency-levels.test.cjs",
|
||||||
"phase.test.cjs"
|
"phase.test.cjs"
|
||||||
|
|||||||
@@ -458,6 +458,14 @@ export const PHASE_COMMAND_ALIASES: CommandAlias[] = [
|
|||||||
],
|
],
|
||||||
"subcommand": "scaffold",
|
"subcommand": "scaffold",
|
||||||
"mutation": true
|
"mutation": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical": "phase.list-plans",
|
||||||
|
"aliases": [
|
||||||
|
"phase list-plans"
|
||||||
|
],
|
||||||
|
"subcommand": "list-plans",
|
||||||
|
"mutation": false
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface PhaseHandlers {
|
|||||||
cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void;
|
cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void;
|
||||||
cmdPhaseComplete: (cwd: string, phaseNum: string | undefined, 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;
|
cmdPhaseUatPassed: (cwd: string, phaseNum: string | undefined, raw: boolean, opts?: { policy?: { requireVerification?: boolean } }) => void;
|
||||||
|
cmdPhaseListPlans: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoutePhaseCommandOptions {
|
interface RoutePhaseCommandOptions {
|
||||||
@@ -182,6 +183,11 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp
|
|||||||
phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification } });
|
phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification } });
|
||||||
return { ok: true as const, data: null };
|
return { ok: true as const, data: null };
|
||||||
},
|
},
|
||||||
|
// #1437 — list plan files for a phase
|
||||||
|
'list-plans': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||||
|
phase.cmdPhaseListPlans(cwd, args[2], raw);
|
||||||
|
return { ok: true as const, data: null };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1825,6 +1825,40 @@ function cmdPhaseUatPassed(
|
|||||||
output({ phase: phaseNum, ...report }, raw);
|
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<string, unknown>)['directory'] as string);
|
||||||
|
const scan = scanPhasePlans(phaseDir);
|
||||||
|
const phaseRel = (phaseInfo as unknown as Record<string, unknown>)['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 = {
|
export = {
|
||||||
cmdPhasesList,
|
cmdPhasesList,
|
||||||
cmdPhaseNextDecimal,
|
cmdPhaseNextDecimal,
|
||||||
@@ -1837,5 +1871,6 @@ export = {
|
|||||||
cmdPhaseRemove,
|
cmdPhaseRemove,
|
||||||
cmdPhaseComplete,
|
cmdPhaseComplete,
|
||||||
cmdPhaseUatPassed,
|
cmdPhaseUatPassed,
|
||||||
|
cmdPhaseListPlans,
|
||||||
computeDependencyLevels,
|
computeDependencyLevels,
|
||||||
};
|
};
|
||||||
|
|||||||
125
tests/fix-1437-phase-list-plans.test.cjs
Normal file
125
tests/fix-1437-phase-list-plans.test.cjs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for `gsd-tools query phase.list-plans <N>` (#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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,7 @@ function makePhase(overrides = {}) {
|
|||||||
cmdPhaseInsert: () => {},
|
cmdPhaseInsert: () => {},
|
||||||
cmdPhaseRemove: () => {},
|
cmdPhaseRemove: () => {},
|
||||||
cmdPhaseComplete: () => {},
|
cmdPhaseComplete: () => {},
|
||||||
|
cmdPhaseListPlans: () => {},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -280,26 +281,27 @@ describe('phase-command-router — result translation (error path)', () => {
|
|||||||
assert.ok(msg !== null);
|
assert.ok(msg !== null);
|
||||||
assert.ok(msg.includes('exactly one phase number'));
|
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 ────────────────────────────────────────────────
|
// ─── 3. Unsupported subcommands ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('phase-command-router — unsupported subcommands', () => {
|
describe('phase-command-router — unsupported subcommands', () => {
|
||||||
test('phase list-plans resolves as unknown subcommand', () => {
|
// #1437: phase list-plans is now a supported subcommand — routing test in § 1.
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phase list-artifacts resolves as unknown subcommand', () => {
|
test('phase list-artifacts resolves as unknown subcommand', () => {
|
||||||
let msg = null;
|
let msg = null;
|
||||||
routePhaseCommand({
|
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('add'), `expected add in available list: ${msg}`);
|
||||||
assert.ok(msg.includes('complete'), `expected complete 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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user