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
This commit is contained in:
Tom Boucher
2026-04-20 18:19:14 -04:00
committed by GitHub
parent 86fb9c85c3
commit e8ec42082d
4 changed files with 179 additions and 5 deletions

View File

@@ -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 {

View File

@@ -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;
}
/**

View File

@@ -11,13 +11,17 @@ Read all files referenced by the invoking prompt's execution_context before star
<step name="parse_args">
**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
```
</step>
@@ -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 |
</error_codes>
@@ -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

View File

@@ -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');
});