feat(roadmap): surface wave dependencies and cross-cutting constraints (#2487)

* feat(roadmap): surface wave dependencies and cross-cutting constraints (#2447)

Adds roadmap.annotate-dependencies command that post-processes a phase's
ROADMAP plan list to insert wave dependency notes and surface must_haves.truths
entries shared across 2+ plans as cross-cutting constraints. Operation is
idempotent and purely derived from existing PLAN frontmatter.

Closes #2447

* fix(roadmap): address CodeRabbit review findings on PR #2487

- roadmap.cjs: expand idempotency guard to also check for existing
  cross-cutting constraints header, preventing duplicate injection on
  re-runs; add content equality check before writing to preserve
  true idempotency for single-wave phases
- plan-phase.md: move ROADMAP annotation (13d) before docs commit (13c)
  so annotated ROADMAP.md is included in the commit rather than left dirty;
  include .planning/ROADMAP.md in committed files list
- sdk/src/query/index.ts: add annotate-dependencies aliases to
  QUERY_MUTATION_COMMANDS so the mutation is properly event-wired
- sdk/src/query/roadmap.ts: add timeout (15s) and maxBuffer to spawnSync;
  check result.error before result.status to handle spawn/timeout failures

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:
Tom Boucher
2026-04-20 18:19:21 -04:00
committed by GitHub
parent e8ec42082d
commit bd27d4fabe
6 changed files with 441 additions and 6 deletions

View File

@@ -49,6 +49,7 @@
* roadmap get-phase <phase> Extract phase section from ROADMAP.md
* roadmap analyze Full roadmap parse with disk status
* roadmap update-plan-progress <N> Update progress table row from disk (PLAN vs SUMMARY counts)
* roadmap annotate-dependencies <N> Add wave dependency notes + cross-cutting constraints to ROADMAP.md
*
* Requirements Operations:
* requirements mark-complete <ids> Mark requirement IDs as complete in REQUIREMENTS.md
@@ -690,8 +691,10 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
roadmap.cmdRoadmapAnalyze(cwd, raw);
} else if (subcommand === 'update-plan-progress') {
roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw);
} else if (subcommand === 'annotate-dependencies') {
roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw);
} else {
error('Unknown roadmap subcommand. Available: get-phase, analyze, update-plan-progress');
error('Unknown roadmap subcommand. Available: get-phase, analyze, update-plan-progress, annotate-dependencies');
}
break;
}

View File

@@ -353,8 +353,171 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
}, raw, `${summaryCount}/${planCount} ${status}`);
}
/**
* Annotate the ROADMAP.md plan list for a phase with wave dependency notes
* and a cross-cutting constraints subsection derived from PLAN frontmatter.
*
* Wave dependency notes: "Wave 2 — blocked on Wave 1 completion" inserted as
* bold headers before each wave group in the plan checklist.
*
* Cross-cutting constraints: must_haves.truths strings that appear in 2+ plans
* are surfaced in a "Cross-cutting constraints" subsection below the plan list.
*
* The operation is idempotent: if wave headers already exist in the section
* the function returns without modifying the file.
*/
function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
if (!phaseNum) {
error('phase number required for roadmap annotate-dependencies');
}
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ updated: false, reason: 'ROADMAP.md not found' }, raw, 'no roadmap');
return;
}
const phaseInfo = findPhaseInternal(cwd, phaseNum);
if (!phaseInfo || phaseInfo.plans.length === 0) {
output({ updated: false, reason: 'no plans found for phase', phase: phaseNum }, raw, 'no plans');
return;
}
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
// Read each PLAN.md and extract wave + must_haves.truths
const planData = [];
for (const planFile of phaseInfo.plans) {
const planPath = path.join(path.resolve(cwd, phaseInfo.directory), planFile);
try {
const content = fs.readFileSync(planPath, 'utf-8');
const fm = extractFrontmatter(content);
const wave = parseInt(fm.wave, 10) || 1;
const planId = planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
const truths = parseMustHavesBlock(content, 'truths') || [];
planData.push({ planFile, planId, wave, truths });
} catch { /* skip unreadable plans */ }
}
if (planData.length === 0) {
output({ updated: false, reason: 'could not read plan frontmatter' }, raw, 'no frontmatter');
return;
}
// Group plans by wave (sorted)
const waveGroups = new Map();
for (const p of planData) {
if (!waveGroups.has(p.wave)) waveGroups.set(p.wave, []);
waveGroups.get(p.wave).push(p);
}
const waves = [...waveGroups.keys()].sort((a, b) => a - b);
// Find cross-cutting truths: appear in 2+ plans (de-duplicated, case-insensitive)
const truthCounts = new Map();
for (const { truths } of planData) {
const seen = new Set();
for (const t of truths) {
const key = t.trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
truthCounts.set(key, (truthCounts.get(key) || { count: 0, text: t.trim() }));
truthCounts.get(key).count++;
}
}
const crossCuttingTruths = [...truthCounts.values()]
.filter(v => v.count >= 2)
.map(v => v.text);
// Patch ROADMAP.md
let updated = false;
withPlanningLock(cwd, () => {
let content = fs.readFileSync(roadmapPath, 'utf-8');
// Find the phase section
const phaseEscaped = escapeRegex(phaseNum);
const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}:[^\\n]*)`, 'i');
const phaseMatch = content.match(phaseHeaderPattern);
if (!phaseMatch) return;
const phaseStart = phaseMatch.index;
const restAfterHeader = content.slice(phaseStart);
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
const phaseSection = content.slice(phaseStart, phaseEnd);
// Idempotency: skip if annotation markers already present
if (
/\*\*Wave\s+\d+/i.test(phaseSection) ||
/\*\*Cross-cutting constraints:\*\*/i.test(phaseSection)
) return;
// Find the Plans: section within the phase section
const plansBlockMatch = phaseSection.match(/(Plans:\s*\n)((?:\s*-\s*\[[ x]\][^\n]*\n?)*)/i);
if (!plansBlockMatch) return;
const plansHeader = plansBlockMatch[1];
const existingList = plansBlockMatch[2];
const listLines = existingList.split('\n').filter(l => /^\s*-\s*\[/.test(l));
if (listLines.length === 0) return;
// Build wave-annotated plan list
const linesByWave = new Map();
for (const line of listLines) {
// Match plan ID from line: "- [ ] 01-01-PLAN.md — ..." or "- [ ] 01-01: ..."
const idMatch = line.match(/\[\s*[x ]\s*\]\s*([\w-]+?)(?:-PLAN\.md|\.md|:|\s—)/i);
const planId = idMatch ? idMatch[1] : null;
const planEntry = planId ? planData.find(p => p.planId === planId) : null;
const wave = planEntry ? planEntry.wave : 1;
if (!linesByWave.has(wave)) linesByWave.set(wave, []);
linesByWave.get(wave).push(line);
}
const annotatedLines = [];
const sortedWaves = [...linesByWave.keys()].sort((a, b) => a - b);
for (let i = 0; i < sortedWaves.length; i++) {
const w = sortedWaves[i];
const waveLines = linesByWave.get(w);
if (sortedWaves.length > 1) {
const dep = i > 0 ? ` *(blocked on Wave ${sortedWaves[i - 1]} completion)*` : '';
annotatedLines.push(`**Wave ${w}**${dep}`);
}
annotatedLines.push(...waveLines);
if (i < sortedWaves.length - 1) annotatedLines.push('');
}
// Append cross-cutting constraints subsection if any found
if (crossCuttingTruths.length > 0) {
annotatedLines.push('');
annotatedLines.push('**Cross-cutting constraints:**');
for (const t of crossCuttingTruths) {
annotatedLines.push(`- ${t}`);
}
}
const newListBlock = annotatedLines.join('\n') + '\n';
const newPhaseSection = phaseSection.replace(
plansBlockMatch[0],
plansHeader + newListBlock
);
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
if (nextContent === content) return;
atomicWriteFileSync(roadmapPath, nextContent);
updated = true;
});
output({
updated,
phase: phaseNum,
waves: waves.length,
cross_cutting_constraints: crossCuttingTruths.length,
}, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : 'skipped (already annotated or no plan list)');
}
module.exports = {
cmdRoadmapGetPhase,
cmdRoadmapAnalyze,
cmdRoadmapUpdatePlanProgress,
cmdRoadmapAnnotateDependencies,
};

View File

@@ -1149,15 +1149,29 @@ gsd-sdk query state.planned-phase --phase "${PHASE_NUMBER}" --name "${PHASE_NAME
This updates STATUS to "Ready to execute", sets the correct plan count, and timestamps Last Activity.
## 13c. Commit Plans if commit_docs is true
## 13c. Annotate ROADMAP with Wave Dependencies and Cross-cutting Constraints
If `commit_docs` is true (from the init JSON parsed in step 1), commit the generated plan artifacts:
After plans are finalized, annotate the ROADMAP.md plan list for this phase with:
- **Wave dependency notes** — a bold header before each wave group ("Wave 2 *(blocked on Wave 1 completion)*")
- **Cross-cutting constraints** — a "Cross-cutting constraints:" subsection listing `must_haves.truths` entries that appear in 2 or more plans
This step is derived entirely from existing PLAN frontmatter — no extra LLM pass is required.
```bash
gsd-sdk query commit "docs(${PADDED_PHASE}): create phase plan" --files "${PHASE_DIR}"/*-PLAN.md .planning/STATE.md
gsd-sdk query roadmap.annotate-dependencies "${PHASE_NUMBER}"
```
This commits all PLAN.md files for the phase plus the updated STATE.md to version-control the planning artifacts. Skip this step if `commit_docs` is false.
This operation is idempotent: if wave headers or cross-cutting constraints already exist in the ROADMAP phase section, the command returns without modifying the file. Skip this step if `plan_count` is 0.
## 13d. Commit Plans if commit_docs is true
If `commit_docs` is true (from the init JSON parsed in step 1), commit the generated plan artifacts (including any ROADMAP.md annotations from step 13c):
```bash
gsd-sdk query commit "docs(${PADDED_PHASE}): create phase plan" --files "${PHASE_DIR}"/*-PLAN.md .planning/STATE.md .planning/ROADMAP.md
```
This commits all PLAN.md files for the phase plus the updated STATE.md and ROADMAP.md to version-control the planning artifacts. Skip this step if `commit_docs` is false.
## 14. Present Final Status

View File

@@ -53,7 +53,7 @@ import {
} from './init.js';
import { initNewProject, initProgress, initManager } from './init-complex.js';
import { agentSkills } from './skills.js';
import { requirementsMarkComplete } from './roadmap.js';
import { requirementsMarkComplete, roadmapAnnotateDependencies } from './roadmap.js';
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
import { statePlannedPhase } from './state-mutation.js';
import { verifySchemaDrift } from './verify.js';
@@ -141,6 +141,7 @@ export const QUERY_MUTATION_COMMANDS = new Set<string>([
'phase add', 'phase add-batch', 'phase insert', 'phase remove', 'phase complete',
'phase scaffold', 'phases clear', 'phases archive',
'roadmap.update-plan-progress', 'roadmap update-plan-progress',
'roadmap.annotate-dependencies', 'roadmap annotate-dependencies',
'requirements.mark-complete', 'requirements mark-complete',
'todo.complete', 'todo complete',
'milestone.complete', 'milestone complete',
@@ -451,6 +452,8 @@ export function createRegistry(
registry.register('agent-skills', agentSkills);
registry.register('roadmap.update-plan-progress', roadmapUpdatePlanProgress);
registry.register('roadmap update-plan-progress', roadmapUpdatePlanProgress);
registry.register('roadmap.annotate-dependencies', roadmapAnnotateDependencies);
registry.register('roadmap annotate-dependencies', roadmapAnnotateDependencies);
registry.register('requirements.mark-complete', requirementsMarkComplete);
registry.register('requirements mark-complete', requirementsMarkComplete);
registry.register('state.planned-phase', statePlannedPhase);

View File

@@ -462,6 +462,53 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir) => {
return { data: result };
};
// ─── roadmapAnnotateDependencies ─────────────────────────────────────────
/**
* Annotate the ROADMAP.md plan list with wave dependency notes and
* cross-cutting constraints derived from PLAN frontmatter.
*
* Delegates to gsd-tools.cjs which holds the full annotation logic.
* Returns { updated, phase, waves, cross_cutting_constraints }.
*/
export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir) => {
const phase = args[0];
if (!phase) {
return { data: { updated: false, reason: 'phase argument required' } };
}
const { spawnSync } = await import('node:child_process');
const { fileURLToPath } = await import('node:url');
const toolsPath = fileURLToPath(
new URL('../../../get-shit-done/bin/gsd-tools.cjs', import.meta.url),
);
const result = spawnSync(process.execPath, [toolsPath, 'roadmap', 'annotate-dependencies', phase], {
cwd: projectDir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000,
maxBuffer: 1024 * 1024,
});
if (result.error) {
return { data: { updated: false, reason: result.error.message || 'gsd-tools invocation failed' } };
}
if (result.status !== 0) {
return { data: { updated: false, reason: result.stderr?.trim() || 'gsd-tools error' } };
}
try {
return { data: JSON.parse(result.stdout.trim()) };
} catch {
return { data: { updated: false, reason: 'failed to parse gsd-tools output' } };
}
};
// ─── requirementsMarkComplete ─────────────────────────────────────────────
/**

View File

@@ -0,0 +1,205 @@
'use strict';
/**
* Tests for ROADMAP wave dependency surfacing (#2447).
*/
const { test, describe, 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');
const PLAN_TEMPLATE = (wave, truths = []) => `---
phase: "1"
plan: "01-0${wave}"
type: standard
wave: ${wave}
depends_on: []
files_modified: []
autonomous: true
requirements: []
must_haves:
truths:
${truths.map(t => ` - ${t}`).join('\n') || ' - (none)'}
artifacts: []
key_links: []
---
<objective>
Plan ${wave} objective
</objective>
`;
function makePlanProject(files = {}) {
const dir = createTempProject();
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '');
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
return dir;
}
describe('roadmap annotate-dependencies', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('inserts wave headers for multi-wave plan set', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true);
assert.strictEqual(out.waves, 2);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present');
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present');
assert.ok(roadmap.includes('blocked on Wave 1'), 'Wave 2 blocked-on note present');
});
test('does not insert wave headers for single-wave plan set', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(1, ['API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('**Wave 1**'), 'no Wave header for single-wave set');
assert.ok(!roadmap.includes('blocked on'), 'no blocked-on note for single wave');
});
test('surfaces cross-cutting constraints when truths appear in 2+ plans', () => {
const sharedTruth = 'All endpoints require auth';
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [sharedTruth, 'DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [sharedTruth, 'API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 1);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Cross-cutting constraints:'), 'constraints subsection present');
assert.ok(roadmap.includes(sharedTruth), 'shared truth listed');
});
test('does not surface constraints that appear in only one plan', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['Only in plan 1']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['Only in plan 2']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 0);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('Cross-cutting constraints:'), 'no constraints section when none are cross-cutting');
});
test('is idempotent — running twice does not double-insert wave headers', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2),
});
runGsdTools('roadmap annotate-dependencies 1', tmpDir);
const secondResult = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(secondResult.success);
const out = JSON.parse(secondResult.output);
assert.strictEqual(out.updated, false, 'second run should be no-op');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const waveMatches = roadmap.match(/\*\*Wave \d+\*\*/g) || [];
assert.strictEqual(waveMatches.length, 2, 'exactly 2 wave headers (not doubled)');
});
test('returns no-op when phase has no plans', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n`,
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, false);
});
test('plan-phase.md documents annotate-dependencies step', () => {
const planPhase = fs.readFileSync(
path.join(__dirname, '../get-shit-done/workflows/plan-phase.md'), 'utf-8'
);
assert.ok(planPhase.includes('annotate-dependencies'), 'plan-phase.md references annotate-dependencies command');
assert.ok(planPhase.includes('13d'), 'plan-phase.md has step 13d');
assert.ok(planPhase.includes('Cross-cutting constraints'), 'plan-phase.md documents cross-cutting constraints');
});
});