fix(#4213): keep STATE.md progress surfaces synchronized (#4231)

* fix(#4213): keep STATE.md progress surfaces synchronized

* fix(#4213): clamp the shared progress bar and keep bold-first priority, changeset + property tests

- formatProgressMachineSegment clamps through clampPercentFromFraction
  (ADR-3180 Decision 7 kernel) with a 0 floor, so a hand-edited
  out-of-range persisted percent renders a clamped bar instead of
  throwing RangeError on repeat() inside the write seam
- stateReplaceProgressPercent restores the #2177 bold-first priority:
  **Progress:** anywhere in the body wins; a plain ^Progress: line is
  the fallback, so free text starting with Progress: cannot capture
  the rewrite ahead of the real status line
- cross-reference comment names the three consumers and the
  cmdStateSync sanctioned exception (ADR-3408 §8.3)
- CONTEXT.md: applyPostSyncPreservation reconciliation documented in
  the STATE.md Transition Module entry
- property tests (never-throws/well-formed, idempotency, round-trip,
  bold-first) + two regression rows through the CLI

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Atirna
2026-09-05 17:26:06 +05:30
committed by GitHub
parent dad16b6ef9
commit 70f22e4643
6 changed files with 261 additions and 42 deletions

View File

@@ -20,7 +20,7 @@ import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stat
import { KNOWN_TEMPLATE_DEFAULTS, toFiniteNumber } from './state-document.cjs';
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs';
import { deriveProgressFromRoadmap, clampPercent, clampPercentFromFraction } from './phase-lifecycle.cjs';
import { escapeRegex } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateMdSchemaMod = require('./state-md-schema.cjs');
@@ -29,6 +29,47 @@ type StateFieldSchema = stateMdSchemaMod.StateFieldSchema;
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter;
export function formatProgressMachineSegment(percent: number): string {
// ADR-3180 Decision 7: rounding and the 100 ceiling belong to the
// completion-ratio kernel. The floor is added here because this helper is
// also fed persisted frontmatter values (hand-editable, unlike the
// count-shaped entries into that kernel), and `'░'.repeat` throws on a
// negative count. Bar and printed percent use the clamped value so the two
// halves of the segment can never disagree.
const clamped = Math.max(0, clampPercentFromFraction(percent / 100));
const filled = Math.round(clamped / 10);
return `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}] ${clamped}%`;
}
// Consumers (a future STATE.md writer that bypasses all three reintroduces the
// #4213 divergence class): `cmdStateUpdateProgress` and `syncCore`'s progress
// intent (both in this module) plus the post-sync body reconciliation in
// `applyPostSyncPreservation` (src/state.cts). `cmdStateSync` never reaches
// that reconciliation — ADR-3408 §8.3: `state sync` lets the body win, so
// preservation must NOT run — which is why its correctness comes from
// `syncCore`'s call here.
export function stateReplaceProgressPercent(content: string, percent: number): string | null {
const body = stripFrontmatter(content);
// #2177: bold `**Progress:**` anywhere in the body wins outright; the plain
// `^Progress:` form is the fallback only when no bold line exists, so an
// earlier free-text line starting with `Progress:` cannot capture the
// rewrite ahead of the real status line.
const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
const pattern = boldProgressPattern.test(body)
? boldProgressPattern
: plainProgressPattern.test(body)
? plainProgressPattern
: null;
if (!pattern) return null;
const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
const progress = formatProgressMachineSegment(percent);
const updatedBody = body.replace(pattern, (_match: string, prefix: string, value: string) => (
`${prefix}${machineSegment.test(value) ? value.replace(machineSegment, progress) : progress}`
));
return content.slice(0, content.length - body.length) + updatedBody;
}
/**
* ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
* `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a
@@ -2771,13 +2812,12 @@ function syncCore(
if (currentProgress) {
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
if (currentPercent !== intent.percent) {
const barWidth = 10;
const filled = Math.round((intent.percent / 100) * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${intent.percent}%`;
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
const result = stateReplaceField(modified, 'Progress', progressStr);
if (result) { modified = result; updated.push('Progress'); }
const result = stateReplaceProgressPercent(modified, intent.percent);
if (result) {
const progressStr = formatProgressMachineSegment(intent.percent);
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
modified = result; updated.push('Progress');
}
}
}
}

View File

@@ -91,7 +91,7 @@ import { findProjectRoot } from './project-root.cjs';
// it introduces no cycle on this path.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import milestoneLockMod = require('./milestone-lock.cjs');
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
// #3699: the frontmatter-key <-> body-field routing behind `state update`'s
// failure explanation, and the classification table it falls back to.
const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
@@ -112,6 +112,7 @@ import {
shouldPreserveExistingProgress,
stateExtractField,
stateFieldValue,
toFiniteNumber,
// #3696: the `last_activity` invariant that `state validate` (S008/S009) now
// asserts. Both live in the field-semantics owner, not here, so `smart-entry`
// and `state validate` cannot drift apart about the same field.
@@ -1445,41 +1446,15 @@ function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
return;
}
const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`;
const progressStr = formatProgressMachineSegment(percent);
let updated = false;
readModifyWriteStateMd(statePath, (content) => {
// #2177: match against the BODY only. With /i the patterns below would
// otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
// eat its newline, mangling the nested block), while the body Progress: line
// — which frontmatter `percent` is re-derived from on every write — stays
// stale and silently reverts the update.
const body = stripFrontmatter(content);
const fmPrefix = content.slice(0, content.length - body.length);
// Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
// descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
const replaceValue = (value: string) => machineSegment.test(value)
? value.replace(machineSegment, progressStr)
: progressStr;
// Try **Progress:** bold format first, then plain Progress: format.
const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
const pattern = boldProgressPattern.test(body)
? boldProgressPattern
: plainProgressPattern.test(body)
? plainProgressPattern
: null;
if (!pattern) return content;
const result = stateReplaceProgressPercent(content, percent);
if (result === null) return content;
updated = true;
return fmPrefix + body.replace(pattern, (_match, prefix: string, value: string) => `${prefix}${replaceValue(value)}`);
return result;
}, cwd);
if (updated) {
@@ -4090,6 +4065,8 @@ function applyPostSyncPreservation(
}
}
let finalContent = syncedContent;
if (preservation.mutated || authoritativeReasserted) {
// #3742: preservation RESTORES frontmatter keys the body-derived rebuild
// could not produce (e.g. `current_phase` on a layout with no body
@@ -4110,9 +4087,16 @@ function applyPostSyncPreservation(
}
const yamlStr = reconstructFrontmatter(preservation.postFm as unknown as Frontmatter);
const body = stripFrontmatter(syncedContent);
return `---\n${yamlStr}\n---\n\n${body}`;
finalContent = `---\n${yamlStr}\n---\n\n${body}`;
}
return syncedContent;
const persistedPercent = toFiniteNumber(
preservation.postFm['progress'] && (preservation.postFm['progress'] as Record<string, unknown>)['percent'],
);
if (persistedPercent !== null) {
const reconciled = stateReplaceProgressPercent(finalContent, persistedPercent);
if (reconciled !== null) finalContent = reconciled;
}
return finalContent;
}
/**