fix(#500): state planned-phase corrupts STATE.md milestone progress.* counters (#514)

* fix(#500): stop state planned-phase corrupting milestone progress.* counters

Two independent defects combined to corrupt STATE.md progress.* on a
plan-phase run:

RC1 — cmdStatePlannedPhase wrote via writeStateMd, which unconditionally
runs syncStateFrontmatter and rebuilds progress.* (total/completed
plans+phases) from a half-planned disk snapshot, trampling curated
counters. It now routes through readModifyWriteStateMd(..., { resync:false }),
the same body-only-write guard state.update uses — per-phase body fields
are updated, milestone progress.* is preserved.

RC2 — isRootPlanFile's loose /PLAN/i fallback matched legacy
`<N>-PLAN-<NN>-SUMMARY.md` names (they contain "PLAN"), double-counting
summaries as plans (a 4-plan/4-summary phase scanned as planCount:8,
completed:false). isRootPlanFile now rejects isRootSummaryFile before the
fallback, so summaries are never counted as plans.

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

* chore(#500): add changeset for planned-phase progress fix

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-30 12:01:01 -04:00
committed by GitHub
parent 5589f4f817
commit d720cef8e9
4 changed files with 186 additions and 28 deletions

View File

@@ -24,6 +24,11 @@ function isRootPlanFile(fileName) {
return false;
if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md')
return true;
// A summary is never a plan. Reject summaries before the loose /PLAN/i
// fallback so legacy `<N>-PLAN-<NN>-SUMMARY.md` names (which contain the
// substring "PLAN") are not double-counted as plans. (#500 RC2)
if (isRootSummaryFile(fileName))
return false;
return /\.md$/i.test(fileName) && /PLAN/i.test(fileName);
}

View File

@@ -1392,45 +1392,48 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
return;
}
let content = fs.readFileSync(statePath, 'utf-8');
const today = realClock.today();
const updated = [];
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
// Update Status — only when the existing value is a known template default
// (Knuth invariant: preserve executor-authored values).
const newContent = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Ready to execute');
if (newContent !== content) { content = newContent; updated.push('Status'); }
// plan-phase updates per-phase body fields only. It must NOT resync the
// milestone-wide progress.* frontmatter from a half-planned disk snapshot —
// doing so tramples curated/known-good counters. Route through the body-only
// write contract (resync:false), the same guard state.update uses. (#500 RC1)
readModifyWriteStateMd(statePath, (content) => {
// Update Status — only when the existing value is a known template default
// (Knuth invariant: preserve executor-authored values).
const newContent = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Ready to execute');
if (newContent !== content) { content = newContent; updated.push('Status'); }
// Update Total Plans in Phase
if (planCount !== null && planCount !== undefined) {
const result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
// Update Total Plans in Phase
if (planCount !== null && planCount !== undefined) {
const result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
// Update Last Activity — only when the existing value is a known template default
{
const after = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
if (after !== content) { content = after; updated.push('Last Activity'); }
}
// Update Last Activity — only when the existing value is a known template default
{
const after = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
if (after !== content) { content = after; updated.push('Last Activity'); }
}
// Update Last Activity Description
{
const result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
if (result) { content = result; updated.push('Last Activity Description'); }
}
// Update Last Activity Description
{
const result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
if (result) { content = result; updated.push('Last Activity Description'); }
}
// Update Current Position section
content = updateCurrentPositionFields(content, {
status: 'Ready to execute',
lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
});
// Update Current Position section
content = updateCurrentPositionFields(content, {
status: 'Ready to execute',
lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
});
if (updated.length > 0) {
writeStateMd(statePath, content, cwd);
}
return content;
}, cwd, { resync: false });
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
}