fix(#1263): restore init phase requirements from flat Phase Details (#1344)

* fix: resolve init phase details fallback

* chore: add changeset for phase details fallback
This commit is contained in:
Tom Boucher
2026-06-16 11:03:47 -04:00
committed by GitHub
parent 18d1611cc6
commit 704d7bc2a6
5 changed files with 199 additions and 41 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1344
---
Init phase lookups now resolve active phases whose canonical details live in a flat Phase Details block outside the current milestone summary, restoring requirement coverage for plan/execute/phase-op flows.

6
package-lock.json generated
View File

@@ -3559,9 +3559,9 @@
}
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"version": "4.12.25",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"

View File

@@ -216,6 +216,33 @@ interface RoadmapPhaseResult {
section: string;
}
function findRoadmapPhaseInContent(content: string, phaseNum: unknown): RoadmapPhaseResult | null {
const phasePattern = new RegExp(
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
if (!headerMatch) return null;
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index!;
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
return {
found: true,
phase_number: String(phaseNum),
phase_name: phaseName,
goal,
section,
};
}
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
if (!phaseNum) return null;
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
@@ -225,31 +252,10 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe
const roadmapRaw = platformReadSync(roadmapPath);
if (roadmapRaw === null) throw new Error('missing');
const content = extractCurrentMilestone(roadmapRaw, cwd);
const phasePattern = new RegExp(
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
if (!headerMatch) return null;
const scopedResult = findRoadmapPhaseInContent(content, phaseNum);
if (scopedResult) return scopedResult;
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index!;
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
return {
found: true,
// eslint-disable-next-line @typescript-eslint/no-base-to-string
phase_number: String(phaseNum),
phase_name: phaseName,
goal,
section,
};
return findRoadmapPhaseInContent(stripShippedMilestones(roadmapRaw), phaseNum);
} catch {
return null;
}

View File

@@ -325,6 +325,142 @@ describe('init commands', () => {
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_req_ids, null);
});
test('init plan-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '11-second-active-phase'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'---',
'milestone: v0.4.0',
'current_phase: 11',
'---',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap: Example',
'',
'## Milestones',
'',
'- ✅ **v0.3.0 Foundations** - Phases 1-9 (shipped 2026-01-01)',
'- 🚧 **v0.4.0 Feature Work** - Phases 10-11 (in progress)',
'',
'## Phases',
'',
'<details>',
'<summary>✅ v0.3.0 Foundations (Phases 1-9) - SHIPPED 2026-01-01</summary>',
'',
'- [x] **Phase 1: Bootstrap**',
'',
'</details>',
'',
'### 🚧 v0.4.0 Feature Work (Active)',
'',
'**Milestone Goal:** Deliver the feature set.',
'',
'- [ ] **Phase 10: First Active Phase**',
'- [ ] **Phase 11: Second Active Phase**',
'',
'### 📋 v0.5+ (Planned)',
'',
'## Phase Details',
'',
'### Phase 10: First Active Phase',
'**Goal**: Build the first piece.',
'**Requirements**: REQ-01',
'',
'### Phase 11: Second Active Phase',
'**Goal**: Build the second piece.',
'**Requirements**: REQ-02, REQ-03',
'',
'## Progress',
'',
].join('\n'));
const result = runGsdTools('init plan-phase 11', tmpDir);
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
});
test('init execute-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
seedPhase(tmpDir, '11-second-active-phase', {
'11-01-PLAN.md': '# Plan',
});
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'---',
'milestone: v0.4.0',
'current_phase: 11',
'---',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap: Example',
'',
'## Phases',
'',
'### 🚧 v0.4.0 Feature Work (Active)',
'',
'- [ ] **Phase 10: First Active Phase**',
'- [ ] **Phase 11: Second Active Phase**',
'',
'### 📋 v0.5+ (Planned)',
'',
'## Phase Details',
'',
'### Phase 10: First Active Phase',
'**Goal**: Build the first piece.',
'**Requirements**: REQ-01',
'',
'### Phase 11: Second Active Phase',
'**Goal**: Build the second piece.',
'**Requirements**: REQ-02, REQ-03',
'',
].join('\n'));
const result = runGsdTools('init execute-phase 11', tmpDir);
assert.ok(result.success, `init execute-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
});
test('init phase-op resolves a details-summary milestone phase from later flat Phase Details', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'milestone: v1.11',
'current_phase: 86',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap',
'',
'## Phases',
'<details open>',
'<summary>🔄 v1.11 A06 (Phases 86-91) — IN PROGRESS</summary>',
'',
'- [ ] **Phase 86: Details Block Regression** — Parser should resolve this (DATA-01)',
'- [ ] **Phase 87: Other Work** — Later phase',
'</details>',
'',
'## Phase Details',
'',
'### Phase 86: Details Block Regression',
'**Goal**: Resolve phase details after collapsed milestone block',
'**Requirements**: DATA-01',
'',
'### Phase 87: Other Work',
'**Goal**: Not relevant',
'',
].join('\n'));
const result = runGsdTools('init phase-op 86', tmpDir);
assert.ok(result.success, `init phase-op failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'Details Block Regression');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -24,6 +24,7 @@ const ROOT = path.resolve(__dirname, '..');
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
const pkg = require(path.join(ROOT, 'package.json'));
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
const { cleanup } = require('./helpers.cjs');
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
@@ -235,9 +236,9 @@ describe('B: hooks/hooks.json', () => {
// or changes `name` to an invalid form goes red immediately.
//
// C2 (OPPORTUNISTIC) — When the `claude` binary IS on PATH, also run
// `claude plugin validate . --strict` as an end-to-end smoke test.
// This tier provides defence-in-depth for schema changes Claude Code
// may introduce that the fixture hasn't yet captured.
// `claude plugin validate <temp-plugin-root> --strict` as an end-to-end
// smoke test. This tier provides defence-in-depth for schema changes
// Claude Code may introduce that the fixture hasn't yet captured.
//
describe('C: plugin.json schema validation', () => {
@@ -366,19 +367,29 @@ describe('C: plugin.json schema validation', () => {
})();
test(
'C2: claude plugin validate . --strict exits 0 (opportunistic — skip when claude not on PATH)',
'C2: claude plugin validate --strict exits 0 (opportunistic — skip when claude not on PATH)',
{ skip: !claudeAvailable ? 'claude binary not on PATH' : false },
() => {
const result = spawnSync('claude', ['plugin', 'validate', '.', '--strict'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 15000,
});
assert.equal(
result.status,
0,
`claude plugin validate . --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
);
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plugin-validate-'));
try {
fs.mkdirSync(path.join(pluginRoot, '.claude-plugin'), { recursive: true });
fs.copyFileSync(PLUGIN_JSON_PATH, path.join(pluginRoot, '.claude-plugin', 'plugin.json'));
fs.symlinkSync(path.join(ROOT, 'commands'), path.join(pluginRoot, 'commands'), 'dir');
fs.symlinkSync(path.join(ROOT, 'hooks'), path.join(pluginRoot, 'hooks'), 'dir');
const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 15000,
});
assert.equal(
result.status,
0,
`claude plugin validate ${pluginRoot} --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
);
} finally {
cleanup(pluginRoot);
}
}
);
});