diff --git a/.changeset/humble-cats-rally.md b/.changeset/humble-cats-rally.md
new file mode 100644
index 000000000..5b4bd97ff
--- /dev/null
+++ b/.changeset/humble-cats-rally.md
@@ -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.
diff --git a/package-lock.json b/package-lock.json
index 42e8d83f6..2826d75fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts
index 3d5fcf100..7452def93 100644
--- a/src/roadmap-parser.cts
+++ b/src/roadmap-parser.cts
@@ -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;
}
diff --git a/tests/init.test.cjs b/tests/init.test.cjs
index cb554c2ab..e9456d36a 100644
--- a/tests/init.test.cjs
+++ b/tests/init.test.cjs
@@ -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',
+ '',
+ '',
+ '✅ v0.3.0 Foundations (Phases 1-9) - SHIPPED 2026-01-01
',
+ '',
+ '- [x] **Phase 1: Bootstrap**',
+ '',
+ ' ',
+ '',
+ '### 🚧 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',
+ '',
+ '🔄 v1.11 A06 (Phases 86-91) — IN PROGRESS
',
+ '',
+ '- [ ] **Phase 86: Details Block Regression** — Parser should resolve this (DATA-01)',
+ '- [ ] **Phase 87: Other Work** — Later phase',
+ ' ',
+ '',
+ '## 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');
+ });
});
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/issue-766-plugin-manifest.test.cjs
index 34cdb8a00..fbf627428 100644
--- a/tests/issue-766-plugin-manifest.test.cjs
+++ b/tests/issue-766-plugin-manifest.test.cjs
@@ -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 --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);
+ }
}
);
});