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:
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user