diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index f636a37f4..9b07624d9 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -49,6 +49,7 @@ * roadmap get-phase Extract phase section from ROADMAP.md * roadmap analyze Full roadmap parse with disk status * roadmap update-plan-progress Update progress table row from disk (PLAN vs SUMMARY counts) + * roadmap annotate-dependencies Add wave dependency notes + cross-cutting constraints to ROADMAP.md * * Requirements Operations: * requirements mark-complete 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; } diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 4a7aff7e7..eaff86481 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -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, }; diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index 695619693..a9ca7b1d0 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -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 diff --git a/sdk/src/query/index.ts b/sdk/src/query/index.ts index 014134211..6adb6365f 100644 --- a/sdk/src/query/index.ts +++ b/sdk/src/query/index.ts @@ -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([ '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); diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index e3df4b53e..1c151096f 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -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 ───────────────────────────────────────────── /** diff --git a/tests/enh-2447-roadmap-wave-deps.test.cjs b/tests/enh-2447-roadmap-wave-deps.test.cjs new file mode 100644 index 000000000..0a35add86 --- /dev/null +++ b/tests/enh-2447-roadmap-wave-deps.test.cjs @@ -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: [] +--- + + +Plan ${wave} 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'); + }); +});