From e8ec42082dd98e49d310a1725b8c3929c17b9858 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 20 Apr 2026 18:19:14 -0400 Subject: [PATCH] feat(health): detect MILESTONES.md drift from archived snapshots (#2446) (#2486) Adds W018 warning when .planning/milestones/vX.Y-ROADMAP.md snapshots exist without a corresponding entry in MILESTONES.md. Introduces --backfill flag to synthesize missing entries from snapshot titles. Closes #2446 --- get-shit-done/bin/gsd-tools.cjs | 3 +- get-shit-done/bin/lib/verify.cjs | 71 +++++++++++++++- get-shit-done/workflows/health.md | 10 ++- tests/enh-2446-milestones-drift.test.cjs | 100 +++++++++++++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 tests/enh-2446-milestones-drift.test.cjs diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 382aa4cb0..f636a37f4 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -764,7 +764,8 @@ async function runCommand(command, args, cwd, raw, defaultValue) { verify.cmdValidateConsistency(cwd, raw); } else if (subcommand === 'health') { const repairFlag = args.includes('--repair'); - verify.cmdValidateHealth(cwd, { repair: repairFlag }, raw); + const backfillFlag = args.includes('--backfill'); + verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw); } else if (subcommand === 'agents') { verify.cmdValidateAgents(cwd, raw); } else { diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 048ff9b31..b0586aff9 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -871,6 +871,38 @@ function cmdValidateHealth(cwd, options, raw) { } } catch { /* git worktree not available or not a git repo — skip silently */ } + // ─── Check 12: MILESTONES.md / archive snapshot drift (#2446) ───────────── + const milestonesPath = path.join(planBase, 'MILESTONES.md'); + const milestonesArchiveDir = path.join(planBase, 'milestones'); + const missingFromRegistry = []; + try { + if (fs.existsSync(milestonesArchiveDir)) { + const archiveFiles = fs.readdirSync(milestonesArchiveDir); + const archivedVersions = archiveFiles + .map(f => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/)) + .filter(Boolean) + .map(m => m[1]); + + if (archivedVersions.length > 0) { + const registryContent = fs.existsSync(milestonesPath) + ? fs.readFileSync(milestonesPath, 'utf-8') + : ''; + for (const ver of archivedVersions) { + if (!registryContent.includes(`## ${ver}`)) { + missingFromRegistry.push(ver); + } + } + if (missingFromRegistry.length > 0) { + addIssue('warning', 'W018', + `MILESTONES.md missing ${missingFromRegistry.length} archived milestone(s): ${missingFromRegistry.join(', ')}`, + 'Run /gsd-health --backfill to synthesize missing entries from archive snapshots', + true); + repairs.push('backfillMilestones'); + } + } + } + } catch { /* intentionally empty — milestone sync check is advisory */ } + // ─── Perform repairs if requested ───────────────────────────────────────── const repairActions = []; if (options.repair && repairs.length > 0) { @@ -960,6 +992,39 @@ function cmdValidateHealth(cwd, options, raw) { } break; } + case 'backfillMilestones': { + if (!options.backfill && !options.repair) break; + const today = new Date().toISOString().split('T')[0]; + let backfilled = 0; + for (const ver of missingFromRegistry) { + try { + const snapshotPath = path.join(milestonesArchiveDir, `${ver}-ROADMAP.md`); + const snapshot = fs.existsSync(snapshotPath) ? fs.readFileSync(snapshotPath, 'utf-8') : null; + // Build minimal entry from snapshot title or version + const titleMatch = snapshot && snapshot.match(/^#\s+(.+)$/m); + const milestoneName = titleMatch ? titleMatch[1].replace(/^Milestone\s+/i, '').replace(/^v[\d.]+\s*/, '').trim() : ver; + const entry = `## ${ver}${milestoneName && milestoneName !== ver ? ` ${milestoneName}` : ''} (Backfilled: ${today})\n\n**Note:** Synthesized from archive snapshot by \`/gsd-health --backfill\`. Original completion date unknown.\n\n---\n\n`; + const milestonesContent = fs.existsSync(milestonesPath) + ? fs.readFileSync(milestonesPath, 'utf-8') + : ''; + if (!milestonesContent.trim()) { + fs.writeFileSync(milestonesPath, `# Milestones\n\n${entry}`, 'utf-8'); + } else { + const headerMatch = milestonesContent.match(/^(#{1,3}\s+[^\n]*\n\n?)/); + if (headerMatch) { + const header = headerMatch[1]; + const rest = milestonesContent.slice(header.length); + fs.writeFileSync(milestonesPath, header + entry + rest, 'utf-8'); + } else { + fs.writeFileSync(milestonesPath, entry + milestonesContent, 'utf-8'); + } + } + backfilled++; + } catch { /* intentionally empty — partial backfill is acceptable */ } + } + repairActions.push({ action: repair, success: true, detail: `Backfilled ${backfilled} milestone(s) into MILESTONES.md` }); + break; + } } } catch (err) { repairActions.push({ action: repair, success: false, error: err.message }); @@ -980,14 +1045,16 @@ function cmdValidateHealth(cwd, options, raw) { const repairableCount = errors.filter(e => e.repairable).length + warnings.filter(w => w.repairable).length; - output({ + const result = { status, errors, warnings, info, repairable_count: repairableCount, repairs_performed: repairActions.length > 0 ? repairActions : undefined, - }, raw); + }; + output(result, raw); + return result; } /** diff --git a/get-shit-done/workflows/health.md b/get-shit-done/workflows/health.md index 6c6352692..a20ea4c7b 100644 --- a/get-shit-done/workflows/health.md +++ b/get-shit-done/workflows/health.md @@ -11,13 +11,17 @@ Read all files referenced by the invoking prompt's execution_context before star **Parse arguments:** -Check if `--repair` flag is present in the command arguments. +Check if `--repair` or `--backfill` flags are present in the command arguments. ``` REPAIR_FLAG="" +BACKFILL_FLAG="" if arguments contain "--repair"; then REPAIR_FLAG="--repair" fi +if arguments contain "--backfill"; then + BACKFILL_FLAG="--backfill" +fi ``` @@ -25,7 +29,7 @@ fi **Run health validation:** ```bash -gsd-sdk query validate.health $REPAIR_FLAG +gsd-sdk query validate.health $REPAIR_FLAG $BACKFILL_FLAG ``` Parse JSON output: @@ -138,6 +142,7 @@ Report final status. | W007 | warning | Phase on disk but not in ROADMAP | No | | W008 | warning | config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip) | Yes | | W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No | +| W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) | | I001 | info | Plan without SUMMARY (may be in progress) | No | @@ -150,6 +155,7 @@ Report final status. | resetConfig | Delete + recreate config.json | Loses custom settings | | regenerateState | Create STATE.md from ROADMAP structure when it is missing | Loses session history | | addNyquistKey | Add workflow.nyquist_validation: true to config.json | None — matches existing default | +| backfillMilestones | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots | None — additive only; triggered by `--backfill` flag | **Not repairable (too risky):** - PROJECT.md, ROADMAP.md content diff --git a/tests/enh-2446-milestones-drift.test.cjs b/tests/enh-2446-milestones-drift.test.cjs new file mode 100644 index 000000000..cbb2646c3 --- /dev/null +++ b/tests/enh-2446-milestones-drift.test.cjs @@ -0,0 +1,100 @@ +'use strict'; + +/** + * Tests for gsd-health MILESTONES.md drift detection (#2446). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { cmdValidateHealth } = require('../get-shit-done/bin/lib/verify.cjs'); + +function makeTempProject(files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2446-')); + fs.mkdirSync(path.join(dir, '.planning', 'milestones'), { 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; +} + +test('W018: warns when archived snapshot has no MILESTONES.md entry', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', + // No MILESTONES.md entry for v1.0 + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w018 = result.warnings.find(w => w.code === 'W018'); + assert.ok(w018, 'W018 warning should be emitted'); + assert.ok(w018.message.includes('v1.0'), 'warning should mention v1.0'); + assert.ok(w018.repairable, 'W018 should be marked repairable'); +}); + +test('no W018 when all snapshots have MILESTONES.md entries', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', + '.planning/MILESTONES.md': '# Milestones\n\n## v1.0 My App (Shipped: 2026-01-01)\n\n---\n\n', + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w018 = result.warnings.find(w => w.code === 'W018'); + assert.strictEqual(w018, undefined, 'no W018 when entries are present'); +}); + +test('no W018 when milestones archive dir is empty', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + // No snapshots in milestones/ + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w018 = result.warnings.find(w => w.code === 'W018'); + assert.strictEqual(w018, undefined, 'no W018 with empty archive dir'); +}); + +test('--backfill synthesizes missing MILESTONES.md entry from snapshot', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n', + }); + + cmdValidateHealth(dir, { repair: true, backfill: true }, false); + + const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md'); + assert.ok(fs.existsSync(milestonesPath), 'MILESTONES.md should be created'); + const content = fs.readFileSync(milestonesPath, 'utf-8'); + assert.ok(content.includes('## v1.0'), 'backfilled entry should contain v1.0'); + assert.ok(content.includes('Backfilled'), 'should note it was backfilled'); +}); + +test('health.md mentions --backfill flag', () => { + const healthMd = fs.readFileSync( + path.join(__dirname, '../get-shit-done/workflows/health.md'), 'utf-8' + ); + assert.ok(healthMd.includes('--backfill'), 'health.md should document --backfill'); + assert.ok(healthMd.includes('W018'), 'health.md should list W018 error code'); + assert.ok(healthMd.includes('backfillMilestones'), 'repair_actions should include backfillMilestones'); +});