fix(#905): preserve STATE.md frontmatter scalars in syncStateFrontmatter (#907)

syncStateFrontmatter was silently dropping current_phase, current_phase_name,
current_plan, and progress when body annotations were absent (e.g. after an
agent or tool rewrote the body). These scalars can only be derived from body
annotations — when absent, buildStateFrontmatter returns nothing for those
keys. Added existingFm fallbacks mirroring the same pattern already applied in
cmdStateJson, so every writeStateMd call preserves the existing values instead
of stripping them. Also extended cmdStateJson with the same fallbacks for the
three non-progress scalars.

Adds regression test (7 cases) + lint-test-file-count allowlist entry.

Closes #905

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 22:52:43 -04:00
committed by GitHub
parent 808df9110c
commit d12809985e
4 changed files with 368 additions and 0 deletions

View File

@@ -1073,6 +1073,36 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string
derivedFm['status'] = existingFm['status'];
}
// Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
// from body annotations (Current Phase:, Current Plan:, etc.). When those
// annotations are absent — e.g. after an agent or tool rewrites the body —
// buildStateFrontmatter returns no value for those keys. Mirror the same
// fallback pattern used in cmdStateJson so the existing frontmatter values
// survive every writeStateMd call.
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
derivedFm['stopped_at'] = existingFm['stopped_at'];
}
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
derivedFm['paused_at'] = existingFm['paused_at'];
}
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
derivedFm['current_phase'] = existingFm['current_phase'];
}
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
}
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
derivedFm['current_plan'] = existingFm['current_plan'];
}
// progress is a sub-object: fall back to existing only when the body+disk
// scan produced NO progress block at all. When buildStateFrontmatter did
// derive a progress block (even a lower one), that derived value wins — the
// shouldPreserveExistingProgress cross-milestone logic is applied later in
// cmdStateJson on the read path where it is appropriate.
if (!derivedFm['progress'] && existingFm['progress']) {
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
}
const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter);
return `---\n${yamlStr}\n---\n\n${body}`;
}
@@ -1265,6 +1295,17 @@ function cmdStateJson(cwd: string, raw: boolean): void {
if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
built['status'] = existingFm['status'];
}
// Bug #905: preserve scalar fields when body annotations are absent.
// Mirrors the same fallback pattern applied in syncStateFrontmatter.
if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
built['current_phase'] = existingFm['current_phase'];
}
if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
built['current_phase_name'] = existingFm['current_phase_name'];
}
if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
built['current_plan'] = existingFm['current_plan'];
}
// Preserve curated cross-milestone aggregates when local disk scanning sees
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
// rebuild from disk because they do not exceed the derived scan.