feat(state): add programmatic gates for STATE.md consistency (#1647)

* feat(state): add programmatic gates for STATE.md consistency

Adds four enforcement gates to prevent STATE.md drift:
- `state validate`: detects drift between STATE.md and filesystem
- `state sync`: reconstructs STATE.md from actual project state
- `state planned-phase`: records state after plan-phase completes
- Performance Metrics update in `phase complete`

Also fixes ghost `state update-position` command reference in
execute-phase.md (command didn't exist in CLI dispatcher).

Closes #1627

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(state): By Phase table regex ate next section when table body was empty

The lazy [\s\S]*? with a $ lookahead in byPhaseTablePattern would
match past blank lines and capture the next ## section header as table
body when no data rows existed. Replaced with a precise row-matching
pattern ((?:[ \t]*\|[^\n]*\n)*) that only captures pipe-delimited
lines. Added regression assertion to verify row placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-04 08:01:39 -04:00
committed by GitHub
parent 1e43accd95
commit f26e1e1141
9 changed files with 841 additions and 24 deletions

View File

@@ -2034,8 +2034,6 @@ describe('phase resolution uses exact token matching', () => {
});
test('1009 must NOT match 1009A-feature-consistency when 1009 dir is absent', () => {
// With only 1009A on disk, searching for 1009 should return not-found
// because 1009 !== 1009A (prefix match bug: '1009A-...' starts with '1009')
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency'));
fs.writeFileSync(path.join(phasesDir, '1009A-feature-consistency', 'PLAN.md'), '# Plan');
@@ -2063,8 +2061,6 @@ describe('phase resolution uses exact token matching', () => {
});
test('999.6 must NOT match 999.60-episode-processing when 999.6 dir is absent', () => {
// With only 999.60 on disk, searching for 999.6 should return not-found
// because '999.60-...' starts with '999.6' (prefix match bug)
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing'));
fs.writeFileSync(path.join(phasesDir, '999.60-episode-processing', 'PLAN.md'), '# Plan');
@@ -2112,6 +2108,80 @@ describe('phase resolution uses exact token matching', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase complete — Performance Metrics gate (Step 2 — Gate 4)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete updates Performance Metrics', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('after cmdPhaseComplete: Performance Metrics has updated total plans count', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 2\n**Status:** Executing Phase 2\n**Total Plans in Phase:** 3\n**Current Plan:** 3\n**Completed Phases:** 0\n**Total Phases:** 3\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n');
fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan 2\n');
fs.writeFileSync(path.join(phaseDir, '02-03-PLAN.md'), '# Plan 3\n');
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '02-03-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n`
);
const result = runGsdTools('phase complete 2', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3');
});
test('after cmdPhaseComplete: By Phase table has row for completed phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 1\n**Status:** Executing Phase 1\n**Total Plans in Phase:** 2\n**Current Plan:** 2\n**Completed Phases:** 0\n**Total Phases:** 2\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n`
);
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(stateAfter.match(/\|\s*1\s*\|\s*2\s*\|/), 'By Phase table should have row for phase 1 with 2 plans');
// Row must appear BEFORE the next section, not after it (regression: empty table body regex)
const rowIdx = stateAfter.indexOf('| 1 |');
const accIdx = stateAfter.indexOf('## Accumulated Context');
if (accIdx !== -1) {
assert.ok(rowIdx < accIdx, 'By Phase row must appear before ## Accumulated Context section');
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// milestone complete command
// ─────────────────────────────────────────────────────────────────────────────