Files
msd-core/src/state-document.cts
Tom Boucher 33e393ba4c fix(#4243): anchor stateReplaceField bold form; pin frontmatter round-trip (#4453)
* test(#4243): failing-first regressions for bold-field anchoring and frontmatter round-trip

* fix(#4243): anchor stateReplaceField bold form to line start

The bold branch of stateReplaceField carried no ^ and no /m flag, so a bold
label quoted mid-sentence inside prose — the issue's **Status:** inside an
Accumulated Context bullet — captured the rewrite and destroyed the rest of
its line, silently, whenever a whole-body caller fed the function every
section (beginPhaseCore's tryField, advancePlanCore's Status/Current Plan
writes). The plain branch was always line-anchored; only the bold branch
lagged.

Anchored to ^([ \t]*\*\*Field:\*\*[ \t]*) with /im, reusing #4010's
same-line confinement idiom for the leading class (deliberately not the
issue's suggested ^\s* — it can consume the newlines before the label into
the match) and #4186's recognition-by-anchoring discipline. Frontmatter
half of the issue (unknown-key drops, invented milestone defaults) is
already fixed on next by #2202/#3216/#4129; pinned here with the issue's
requested regression fixtures.

* test(#4243): pin survival contract, not derived percent, in frontmatter rows

Bench RED run caught two assertion defects in the pin rows: the unknown
progress subkey re-parses as a quoted scalar ('77' vs 77), and percent is a
declared derived subkey - omitted under the #3573 no-roadmap withhold,
recomputed when measured (#4129) - so pinning its value over-pins derived
semantics. The rows now pin what the issue demands: unknown/custom keys
survive, stored counters are kept under the withhold, milestone identity is
never reset to invented defaults.

* chore(#4243): changeset for the anchored bold-field fix

* chore(#4243): backfill PR number in changeset
2026-09-07 00:03:15 -04:00

947 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* STATE.md Document Module — pure transforms for STATE.md text.
* This module does not read the filesystem and does not own persistence or locking.
*
* ADR-457 build-at-publish: the hand-written bin/lib/state-document.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import { splitTableRow } from './markdown-table.cjs';
import { clampPercentFromFraction } from './phase-lifecycle.cjs';
import { collectSection, withSection } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
import { escapeRegex } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
/**
* Coerce an arbitrary frontmatter scalar to a finite number, or `null` if it
* is not one. Exported per ADR-3473 §8.6: `state-transition.cts`'s
* progress-ratchet unmeasured-scan check ("is this derived total a real
* measurement?") must ask through the SAME coercion this module already uses
* for `existingProgressExceedsDerived`, rather than growing a second private
* copy. This matters because frontmatter scalars arrive as STRINGS
* (`"0"`, not `0`) — a raw `=== 0` test is wrong at both call sites.
*/
export function toFiniteNumber(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
interface ProgressRecord {
total_phases?: unknown;
completed_phases?: unknown;
total_plans?: unknown;
completed_plans?: unknown;
percent?: unknown;
[key: string]: unknown;
}
function existingProgressExceedsDerived(existingProgress: ProgressRecord, derivedProgress: ProgressRecord, key: string): boolean {
const existing = toFiniteNumber(existingProgress[key]);
const derived = toFiniteNumber(derivedProgress[key]);
return existing !== null && derived !== null && existing > derived;
}
/**
* Return true if a pipe-table row's first cell is a separator cell (`---`
* variants) rather than a field name. Prevents the separator row
* `| --- | --- |` from being treated as a field named "---".
*/
function isTableSeparatorRow(firstCell: string): boolean {
// A separator cell contains only dashes, colons (alignment hints), and whitespace.
return /^[\s\-:]+$/.test(firstCell.trim());
}
function countLeading(str: string): number {
const match = /^[ \t]*/.exec(str);
return match ? match[0].length : 0;
}
/**
* Canonicalize one UTF-16 code unit per the ECMAScript non-unicode
* `Canonicalize` abstract operation, which governs how a case-insensitive
* (`/i`, no `u` flag) RegExp compares characters: take `ch.toUpperCase()`.
* The uppercasing is REJECTED (the original character is kept as-is) in
* either of two cases: (1) `ch.toUpperCase()` does not produce exactly one
* character (e.g. "ß" -> "SS" — a multi-character case-fold can never be a
* per-character regex match, so Canonicalize leaves it alone), or (2) it
* produces exactly one character but the original character's code point is
* >= 128 while the uppercased character's code point is < 128 (this is what
* stops a non-ASCII character from folding onto an ASCII one under `/i` —
* e.g. KELVIN SIGN U+212A uppercases to ASCII "K" (U+004B), so this rule
* rejects the fold and keeps U+212A, meaning `/k/i`/`/K/i` do NOT match
* U+212A). Otherwise, the uppercased character is used. Plain
* `.toLowerCase()`/`.toUpperCase()` folds both of these cases, which is
* exactly why they diverge from real regex `/i` semantics.
*/
function canonicalizeCharForCaselessCompare(ch: string): string {
const upper = ch.toUpperCase();
if (upper.length !== 1) {
return ch;
}
if (ch.charCodeAt(0) >= 128 && upper.charCodeAt(0) < 128) {
return ch;
}
return upper;
}
/**
* Canonicalize a whole string, one UTF-16 code unit at a time, per the
* ECMAScript non-unicode `Canonicalize` rule (see
* canonicalizeCharForCaselessCompare) so that two strings compare equal
* under this function iff a non-`u`-flag `/i` RegExp would treat them as
* the same literal text. This is the correct replacement for
* `.toLowerCase()` when replicating a non-`u` `/i` regex: `.toLowerCase()`
* folds some non-ASCII characters (e.g. KELVIN SIGN U+212A) onto their
* ASCII counterparts, which real `/i` regex semantics do not. Iteration is
* by UTF-16 code unit (not code point) to match how a non-`u` regex engine
* itself operates on surrogate halves individually.
*/
function canonicalizeForCaselessCompare(str: string): string {
let result = '';
for (let i = 0; i < str.length; i++) {
result += canonicalizeCharForCaselessCompare(str[i]);
}
return result;
}
/**
* Return true when the caller's raw (untrimmed) `fieldName` may be considered
* to match a row's raw (untrimmed) field cell text. Faithfully replicates the
* backtracking of the regex this function replaced: `^(\|[ \t]*)(FieldName)
* ([ \t]*\|...)`. Group 1 (`\|[ \t]*`, greedy but backtrackable) can hand any
* PREFIX of the cell's leading `[ \t]` run over to group 2 (the literal,
* case-insensitive `fieldName` text) — so `fieldName` is tried at every offset
* `j` from 0 up to the length of that leading run. For a given `j` to be a
* genuine match, two things must hold: `rawCell.slice(j, j + fieldName.length)`
* must equal `fieldName` case-insensitively (group 2), AND everything left
* over after it — `rawCell.slice(j + fieldName.length)` — must be entirely
* `[ \t]` characters, because group 3 (`[ \t]*\|`) must consume that leftover
* as whitespace before it can reach the delimiter pipe.
*
* A simple count-of-leading/trailing-whitespace comparison is NOT equivalent:
* it ignores that group 2 is a literal-character match, not a whitespace-
* class match, so it can produce false positives whenever `fieldName`'s own
* padding is a different run of `[ \t]` characters than the cell's (e.g.
* `fieldName` padded with spaces against a cell padded with tabs) — caught by
* differential fuzzing against the regex this replaces.
*
* The case-insensitive comparison itself is done via
* canonicalizeForCaselessCompare, NOT `.toLowerCase()`: the replaced regex
* used `/i` WITHOUT the `u` flag, whose case-folding is the ECMAScript
* non-unicode `Canonicalize` operation. `.toLowerCase()` folds some non-ASCII
* characters onto ASCII ones (e.g. KELVIN SIGN U+212A -> "k") that `/i`
* (no `u`) does NOT fold, so `.toLowerCase()` alone would NOT faithfully
* replicate the old regex's semantics; canonicalizeForCaselessCompare does.
*/
function fieldNameMatchesRawCell(fieldName: string, rawCell: string): boolean {
const n = fieldName.length;
const cellLength = rawCell.length;
if (n > cellLength)
return false;
const leadingRun = countLeading(rawCell);
const maxOffset = Math.min(leadingRun, cellLength - n);
const canonicalFieldName = canonicalizeForCaselessCompare(fieldName);
for (let j = 0; j <= maxOffset; j++) {
if (canonicalizeForCaselessCompare(rawCell.slice(j, j + n)) !== canonicalFieldName)
continue;
if (/^[ \t]*$/.test(rawCell.slice(j + n)))
return true;
}
return false;
}
/**
* Locate the value cell of a pipe-table row `| FieldName | value |` for the
* given field name, by scanning `content` line by line (no whole-document
* regex). Only a strict two-column row (exactly 3 `|` chars, starting the
* line, ending the line after trailing space/tab is stripped) is considered;
* this is what makes a 3-column row or an unescaped-pipe-bearing value cell
* fail to match, mirroring the previous regex's behaviour. Separator rows
* (`| --- | --- |`) are skipped, not matched. The match is case-insensitive.
* A line terminator is `\r\n`, a lone `\r`, or a lone `\n` — matching the `m`
* flag semantics of the regex this function replaced. Returns the byte range
* of the value cell (after trimming surrounding space/tab) so the caller can
* splice it directly.
*/
function locateFieldRow(content: string, fieldName: string): { valueStart: number; valueEnd: number; rawValue: string } | null {
let lineStart = 0;
while (lineStart <= content.length) {
// A line terminator is `\r\n`, a lone `\r`, or a lone `\n` (JS treats a
// bare `\r` as a line terminator too — the regex this replaced used the
// `m` flag, which honors all three). Scan for whichever of `\r`/`\n`
// occurs first; if it's `\r` immediately followed by `\n`, the terminator
// is 2 chars wide, otherwise 1.
let terminatorIndex = -1;
let terminatorLength = 0;
for (let i = lineStart; i < content.length; i++) {
const ch = content[i];
if (ch === '\n') {
terminatorIndex = i;
terminatorLength = 1;
break;
}
if (ch === '\r') {
terminatorIndex = i;
terminatorLength = content[i + 1] === '\n' ? 2 : 1;
break;
}
}
const lineEnd = terminatorIndex === -1 ? content.length : terminatorIndex;
const line = content.slice(lineStart, lineEnd);
if (line.startsWith('|')) {
const pipeCount = (line.match(/\|/g) || []).length;
const trimmedEnd = line.replace(/[ \t]+$/, '');
if (pipeCount === 3 && trimmedEnd.endsWith('|')) {
const cells = splitTableRow(line);
if (cells.length === 2 && !isTableSeparatorRow(cells[0])) {
// Line has exactly 3 pipes (enforced above): opening pipe, the
// field/value separator pipe, and the row-closing pipe.
const fieldValueSeparatorPipe = line.indexOf('|', line.indexOf('|') + 1);
const rawCell = line.slice(1, fieldValueSeparatorPipe);
if (fieldNameMatchesRawCell(fieldName, rawCell)) {
const rowClosingPipe = line.indexOf('|', fieldValueSeparatorPipe + 1);
let valueStart = lineStart + fieldValueSeparatorPipe + 1;
while (content[valueStart] === ' ' || content[valueStart] === '\t')
valueStart++;
let valueEnd = lineStart + rowClosingPipe;
while (valueEnd - 1 >= valueStart && (content[valueEnd - 1] === ' ' || content[valueEnd - 1] === '\t'))
valueEnd--;
return { valueStart, valueEnd, rawValue: content.slice(valueStart, valueEnd) };
}
}
}
}
if (terminatorIndex === -1)
break;
lineStart = terminatorIndex + terminatorLength;
}
return null;
}
/**
* True only when y/m/d name a date that actually exists on the calendar.
*
* `Date.parse` validates shape but not value: it rolls an out-of-range day
* FORWARD rather than rejecting it (`2026-02-30` -> `2026-03-02`,
* `2026-04-31` -> `2026-05-01`). Shape-only validation would therefore
* propagate a different, wrong instant instead of failing safe — precisely
* what ADR-227 ("validate shape AND value; on failure of either layer coerce
* to the contract's safe default, never propagate") exists to prevent. A
* round-trip through Date.UTC detects the rollover: any component the
* constructor normalised comes back changed.
*
* #3696: this predicate previously lived privately inside `smart-entry.cts`,
* where it gated `parseActivityTimestamp`. `state validate` needed the same
* answer to assert the `last_activity` invariant (S008), and a second copy is
* the "generative fix divergence" class outright — two surfaces that disagree
* about whether a STATE.md is usable is the defect #3696 opens with, so a
* parity test over two copies would be codifying the bug rather than fixing
* it. It moves here because this module is already the designated owner of
* STATE.md field semantics (ADR-3180 §7.7) and `smart-entry.cts` imports no
* peer that would make the reverse direction a cycle.
*/
/**
* True when a field carries no value a writer ever supplied: absent, blank, or
* still holding the shipped template's bracket placeholder.
*
* `templates/state.md:35` ships `Last activity: [YYYY-MM-DD] — [What happened]`,
* so EVERY freshly-initialized project has this exact string until something
* records activity. #3696's first cut only spared the ABSENT form, which made
* S008 fire on the shipped template itself — caught by the pre-existing
* "template-equivalent phase identities remain clean without disk drift" test,
* which is precisely what it is there for.
*
* The placeholder test is anchored at the START rather than "contains a bracket
* anywhere", so a real description that happens to cite one — `2026-08-19 — fixed
* [#123] parsing` — is still a filled-in value. That keeps the rule from
* silently swallowing genuine drift.
*
* Distinct from `isStateTemplateDefault`, which answers a different question
* ("may a later handler overwrite this?") and deliberately returns true for a
* bare ISO date — a perfectly valid value here.
*/
export function isUnfilledFieldValue(value: string | null | undefined): boolean {
if (value === null || value === undefined) return true;
const trimmed = value.trim();
return trimmed === '' || trimmed.startsWith('[');
}
/**
* The `YYYY-MM-DD` prefix of `value`, but only when it names a date that
* actually exists. `null` for anything else — no leading date token at all, or
* a token that is shape-valid and calendar-impossible.
*
* #3696 review: this is deliberately a LEADING-TOKEN test, not the fully
* anchored prose grammar `parseProseLastActivityField` uses. That function
* requires the whole value to be `date` or `date <separator> description`, and
* returns `{date: <the entire raw string>}` when it does not match — a shape
* that reads like success. Asserting the S008 invariant through it therefore
* rejected values the real reader accepts: `smart-entry`'s
* `parseActivityTimestamp` needs only a leading date and reconstructs the
* instant even when the suffix carries no dash, so
* `Last activity: 2026-08-24 Shipped feature X` parses fine there while S008
* called it unreadable. That is the same two-surfaces-disagree defect #3696
* exists to close, merely pointing the other way.
*
* So the invariant asserted is the one the readers actually share: a leading
* ISO date token that is a real calendar date.
*/
export function leadingCalendarDate(value: string | null): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})(?![\d-])/.exec(value.trim());
if (!match) return null;
return isRealCalendarDate(Number(match[1]), Number(match[2]), Number(match[3]))
? `${match[1]}-${match[2]}-${match[3]}`
: null;
}
export function isRealCalendarDate(year: number, month: number, day: number): boolean {
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
const probe = new Date(Date.UTC(year, month - 1, day));
return (
probe.getUTCFullYear() === year &&
probe.getUTCMonth() === month - 1 &&
probe.getUTCDate() === day
);
}
/**
* Markdown structure that can legitimately follow a single-line field. A line
* matching any of these is the NEXT construct, never a continuation of the
* field above it.
*
* BREADTH IS THE POINT, and the failure direction is deliberate: a missed
* truncation costs a diagnostic nobody sees, while a false S009 reports drift on
* a well-formed STATE.md — a gate that fires on valid documents is worse than no
* gate. When a shape is ambiguous, it belongs here.
*
* #3696 review round 2 added the last three arms after all three were shown to
* produce false S009 fires on well-formed content: an indented code block, an
* HTML block, and a setext underline (`===`, which the `[-*_]{3,}` rule does not
* cover — it only knows `-`, `*` and `_`).
*/
const MD_STRUCTURE_LINE_RE =
/^(?:#{1,6}\s|\||>|```|~~~|[-*_]{3,}\s*$|=+\s*$|[-*+]\s|\d+[.)]\s|\[[^\]]+\]:|<|(?: {4}|\t))/;
/**
* A setext heading's underline — `===` or `---` on its own line. The line ABOVE
* one of these is a heading TITLE, which is indistinguishable from prose on its
* own, so the scan must look ahead by one line rather than consume it. Without
* this, `Last activity: …\nMy Heading\n===` reported "My Heading ===" as dropped
* continuation text (#3696 review round 2).
*/
const SETEXT_UNDERLINE_RE = /^(?:=+|-+)\s*$/;
const STATE_SIBLING_FIELD_LINE_RE = /^\*{0,2}[A-Za-z][A-Za-z0-9 _-]*\*{0,2}:{1,2}\*{0,2}(?:\s|$)/;
/**
* Return the prose that FOLLOWS a single-line field but plainly belongs to it —
* i.e. the remainder `stateExtractField` silently drops when a writer emits a
* value long enough to wrap.
*
* `stateExtractField`'s `(.+)` is newline-excluding, so
*
* Last activity: 2026-08-19 — Project initialized from ingest; PROJECT.md,
* REQUIREMENTS.md, ROADMAP.md written
*
* yields only the first line and the rest is lost with no diagnostic (#3696).
* `templates/state.md` prescribes a single-line field, so the DOCUMENT is what
* is wrong here, not the reader — this function exists so `state validate` can
* SAY so, not so the reader can start guessing at a multi-line grammar the
* template does not sanction.
*
* That is also why the fix is not in `stateExtractField` itself: it has 20
* direct callers and a CRITICAL blast radius (ADR-3180 §7.7, Rejected #1), and
* joining continuations there would apply to every field — `Status:` would
* swallow the line beneath it.
*
* Returns `null` when the field is absent, is a pipe-table row (a table cell
* cannot wrap), or is followed by end-of-file, a blank line, Markdown
* structure, or a sibling field.
*/
export function stateFieldContinuation(content: string, fieldName: string): string | null {
const escaped = escapeRegex(fieldName);
// Same two single-line grammars stateExtractField uses, in the same order, so
// this locates exactly the line whose value it returned. The pipe-table rung
// is deliberately absent: a `| Field | value |` row is bounded by its closing
// pipe and cannot wrap.
const match =
new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i').exec(content) ??
new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im').exec(content);
if (!match) return null;
// `(.+)` stops at the line terminator, so the field's line ends where the
// match does. JS `.` excludes \r as well as \n, so on a CRLF document the \r
// sits just AFTER the match rather than inside it — hence the strip below
// before testing for the newline.
const afterValue = match.index + match[0].length;
const rest = content.slice(afterValue).replace(/^\r/, '');
if (!rest.startsWith('\n')) return null; // end of file: nothing follows
const lines = rest.slice(1).split('\n').map((line) => line.replace(/\r$/, ''));
const continuation: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) break;
if (MD_STRUCTURE_LINE_RE.test(line)) break;
if (STATE_SIBLING_FIELD_LINE_RE.test(line)) break;
// Look ahead one line: a setext underline below makes THIS line a heading
// title, so stop before consuming it rather than after.
if (i + 1 < lines.length && SETEXT_UNDERLINE_RE.test(lines[i + 1])) break;
continuation.push(line.trim());
}
return continuation.length ? continuation.join(' ') : null;
}
export function stateExtractField(content: string, fieldName: string): string | null {
const escaped = escapeRegex(fieldName);
// Bold inline format: **FieldName:** value
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch)
return boldMatch[1].trim();
// Plain line-start format: FieldName: value
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
if (plainMatch)
return plainMatch[1].trim();
// Pipe-table format: | FieldName | value |
// (Separator rows such as `| --- | --- |` are excluded.)
const hit = locateFieldRow(content, fieldName);
if (hit)
return hit.rawValue.trim();
return null;
}
/**
* Single owner of the #1760 STATE.md field-extraction fallback chain: "read
* field F, preferring the YAML frontmatter scalar, falling back to the body
* field." Added for #3187 (epic #3180, ADR-3180 §7.7) to collapse three
* independent re-derivations of this chain — `src/smart-entry.cts`'s
* `fmScalar` closure, and `src/state.cts`'s `cmdStateSnapshot` and
* `cmdStatePrune` — onto one function, per ADR-3180 Decision 1 ("keep N
* copies with a parity test" is rejected: a parity test proves today's
* agreement, not that copy N+1 won't happen).
*
* Takes ALREADY-PARSED `fm` and `body` rather than raw STATE.md content: the
* heaviest caller, `cmdStateSnapshot`, reads roughly ten fields off one parse
* and must not re-parse frontmatter per field.
*
* `stateExtractField` (above) is deliberately left untouched — it has 20
* direct callers and a CRITICAL blast radius (ADR-3180 §7.7's Rejected #1) —
* so this function is additive: it calls `stateExtractField` rather than
* replacing it or changing its signature.
*
* Fallback ladder (unchanged from every prior copy this replaces):
* 1. `fm[fmKey]` is a non-empty (post-`.trim()`) string → that trimmed
* string.
* 2. `fm[fmKey]` is a `number` or `boolean` → `String(fm[fmKey])`, so `0`
* and `false` are VALUES, not absence.
* 3. Anything else (`null`, `undefined`, an object, an array, or an
* empty/whitespace-only string) → fall through to
* `stateExtractField(body, bodyField)`.
*
* `fmKey === null` skips steps 1–2 outright: for a caller whose chain has no
* frontmatter side for this particular field (e.g. `state.cts`'s body-only
* `Last Activity` / `Last activity` case-variant pair, which sits inside a
* function that DOES own a ladder for its other fields, so per this phase's
* function-scoped guard it must still route through this owner).
* `bodyField === null` skips step 3: for a caller whose "no frontmatter
* value" case falls through to an already-computed value instead of a fresh
* extractor call (e.g. `cmdStateSnapshot`'s `last_activity`, which falls to
* its already-parsed prose date rather than re-extracting the body).
*
* `scope` reports whether the chain ran over inputs it could actually
* consult (ADR-3180 Decision 2/§7.7 — mirrors `scanPhasePlans`'s
* scope-carrying result in `plan-scan.cts`; see `planning-scope.cjs`). This
* function's own ladder always runs to completion on whatever `fm`/`body` it
* is given, INCLUDING when the answer is `null` — a genuinely absent field is
* a real answer, not a failure to look (§7.7 behavior table row 4). So
* `scope` defaults to `SCOPE.COMPLETE` and is only ever something else when
* the CALLER passes `opts.scope`, because only the caller knows whether an
* input it handed in was itself degraded — e.g. `fm` came back `{}` from an
* unterminated frontmatter fence (`extractFrontmatter` swallows that parse
* failure), or `body` is an unscoped whole-document fallback because a
* required `## Current Position` section was not found (#2956). This
* function never invents a new `SCOPE` member — the enum is frozen at
* COMPLETE/TRUNCATED/UNSCOPED/UNREADABLE (`planning-scope.cjs`).
*
* #1760 is the fallback chain's origin.
*/
export function stateFieldValue(
fm: Record<string, unknown>,
body: string,
fmKey: string | null,
bodyField: string | null,
opts?: { scope?: Scope },
): { value: string | null; scope: Scope } {
const v = fmKey === null ? undefined : fm[fmKey];
let value: string | null;
if (typeof v === 'string' && v.trim()) {
value = v.trim();
} else if (typeof v === 'number' || typeof v === 'boolean') {
value = String(v);
} else {
value = bodyField === null ? null : stateExtractField(body, bodyField);
}
return { value, scope: opts?.scope ?? SCOPE.COMPLETE };
}
/**
* Match the "Current Position" section body from a STATE.md body. #2956: this
* is the Phase analogue of state.cts's matchSessionSection. `Phase` canonically
* lives under `## Current Position` (gsd-core/templates/state.md), so — like
* Stopped At / Paused At under `## Session` — it must be extracted from THAT
* section, not from the first `Phase:` / `**Phase:**` line anywhere in the
* body. Without the scope, a historical `Phase:` line in an archive section
* silently shadows the real one on every read/write, and because callers use
* this for routing (state.cts's current_phase) and for drift detection
* (gsd-tools.cjs's `drift-guard phase-status` CLI seam), a stale match either
* routes work to the wrong phase or fabricates a drift finding.
*
* Level-flexible: the canonical template uses an h2 `## Current Position`, the
* bootstrap template an h3 `### Current Position` (templates/state.md). Both
* must match — mirroring how matchSessionSection recognises `## Session` and
* `## Session Continuity`. Exact 'current position' text match (case-
* insensitive) excludes unrelated headings. Built on the `collectSection`
* seam, so it inherits that seam's CRLF tolerance (#2444 fix).
*
* This is the single owner of the scope — state.cts's private
* `matchCurrentPositionSection` delegates here rather than duplicating the
* logic, so the two consumers cannot drift apart.
*
* Returns the section body, or null (caller falls back to full-body search).
*/
export function stateCurrentPositionSlice(body: string): string | null {
const isCurrentPosition = (h: HeadingToken): boolean =>
(h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position';
const section = collectSection(body, isCurrentPosition, { levelBounded: true });
return section ? section.body : null;
}
/**
* Join a matched `**Field:**`/`Field:` label prefix to its new value, inserting a
* single space when the prefix does not already end in same-line whitespace.
*
* On an empty field the same-line `[ \t]*` gap (below) captures nothing, so the
* bare `prefix + value` would glue the value to the label (`**Status:**value`);
* this inserts the missing separator. A non-empty field whose label-to-value
* separator is ordinary space/tab keeps that separator in the prefix, so `[ \t]$`
* is true and the output stays byte-identical to prior behaviour. The one
* exception is a non-empty field written with NO separator at all (a hand-edited
* `**Status:**value`): the narrowed gap captures nothing, `[ \t]$` is false, and a
* single space is inserted — an intentional normalization, not byte-identical, and
* with no GSD-template trigger. An empty new value inserts no separator, avoiding a
* dangling trailing space. See #4010.
*/
function joinFieldReplacement(prefix: string, newValue: string): string {
const value = `${newValue}`;
const needsSeparator = value.length > 0 && !/[ \t]$/.test(prefix);
return `${prefix}${needsSeparator ? ' ' : ''}${value}`;
}
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
const escaped = escapeRegex(fieldName);
// Bold inline format: **FieldName:** value
// The label-to-value gap is same-line whitespace only (`[ \t]*`, mirroring the
// read side at stateExtractField). `\s*` here matched `\n`, so on an empty field
// `(.*)` captured the following line and the rebuild discarded it — the #4010
// data-loss. ADR-3180 §7.7 makes stateExtractField the same-line-confined owner;
// this aligns the writer to it.
//
// #4243: the bold form is also ANCHORED to line start, with same-line leading
// whitespace only. The pre-fix pattern carried no `^` and no `m` flag, so a
// bold label quoted MID-SENTENCE inside prose — an Accumulated Context bullet
// mentioning `**Status:**` — captured the rewrite and destroyed the rest of
// its line, silently, whenever a whole-body caller fed this function every
// section (beginPhaseCore's tryField, advancePlanCore's Status/Current Plan
// writes). The plain branch below was always line-anchored; only the bold
// branch lagged. Anchoring reuses #4010's same-line confinement idiom (the
// leading class is `[ \t]*`, deliberately NOT the `\s*` the issue suggested —
// `^\s*\*\*` can consume the newlines before the label into the match and
// drop them on rebuild) and #4186's recognition-by-anchoring discipline: a
// write target must BE the whole declared line shape, never a substring
// guess inside prose. `$` is explicit-and-inert (`.` never crosses line
// terminators) and documents that the match ends at end-of-line.
const boldPattern = new RegExp(`^([ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*)(.*)$`, 'im');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix: string) => joinFieldReplacement(prefix, newValue));
}
// Plain line-start format: FieldName: value (same same-line confinement as above)
const plainPattern = new RegExp(`(^${escaped}:[ \\t]*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix: string) => joinFieldReplacement(prefix, newValue));
}
// Pipe-table format: | FieldName | value |
// Preserve the surrounding pipe/whitespace structure; only swap the value cell.
const hit = locateFieldRow(content, fieldName);
if (hit) {
return content.slice(0, hit.valueStart) + newValue + content.slice(hit.valueEnd);
}
return null;
}
export function stateReplaceFieldWithFallback(content: string, primary: string, fallback: string | null | undefined, value: string): string {
let result = stateReplaceField(content, primary, value);
if (result)
return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result)
return result;
}
return content;
}
/**
* #3374: session-scoped variant of stateReplaceFieldWithFallback for the
* `## Session` continuity fields. The post-sync harvest (state.cts's
* matchSessionSection → buildStateFrontmatter) reads these fields ONLY from
* the session section, so a writer that refreshes one must target the same
* scope — a whole-body replace lets a decoy `**Stopped at:**` line in an
* unrelated (e.g. archive) section absorb the refresh while the harvested
* session value stays stale.
*
* Section preference mirrors the reader exactly: the normalized `## Session`
* block wins over the bootstrap `## Session Continuity` heading when both
* exist (legacy duplicate files); the continuity heading is only consulted
* when no canonical `## Session` section exists. `levelBounded` heading
* matching also excludes `## Session Continuity Archive` (the #2444 scoping).
*
* Replace-only (no insertion): returns `content` unchanged when no session
* section exists or the field is absent from it, so a STATE.md layout without
* the line keeps its shape and the post-sync preservation pass decides the
* frontmatter value (see #3374).
*/
export function stateReplaceFieldInSession(content: string, primary: string, fallback: string | null | undefined, value: string): string {
const isSession = (h: HeadingToken): boolean => h.level === 2 && h.text.trim().toLowerCase() === 'session';
const isSessionContinuity = (h: HeadingToken): boolean => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
const hasCanonicalSession = collectSection(content, isSession, { levelBounded: true }) !== null;
const target = hasCanonicalSession ? isSession : isSessionContinuity;
return withSection(content, target, (sectionBody) => stateReplaceFieldWithFallback(sectionBody, primary, fallback, value));
}
/**
* #4186: the DECLARED raw-status vocabulary `normalizeStateStatus` recognizes.
* Keys are whole-field values, compared against the caller's input after
* lowercasing, trimming, and collapsing internal whitespace runs to single
* spaces — so case and whitespace variants the vocabulary documents
* (`EXECUTING PHASE 5`, ` Paused `, `In progress`) keep normalizing.
* Values are members of `STATUS_LIFECYCLE_ENUM` (`src/state-md-schema.cts`)
* — the set the normalizer maps recognized input ONTO.
*
* The mapping preserves the PRE-#4186 branch ORDER's observable artifacts for
* every value the old substring chain recognized: `Planning complete` →
* `planning` (the `planning` branch outranked `complete`) and
* `Phase complete — ready for verification` → `verifying` (`verif` outranked
* `complete`; pinned by tests/state.test.cjs's advance-plan case-5 comment).
*
* Everything else — prose that merely CONTAINS a status word — falls through
* to the caller's raw value (the recorded lenient fallback, #3873 phase-3
* row 26). A token guessed from a substring inside a sentence is worse than
* a visible paragraph: the paragraph is visibly prose, the wrong token is
* not (a `.planning/` path in Italian prose silently produced
* `status: planning`; `verificata` produced `verifying`; `completezza`
* produced `completed`).
*/
export const STATUS_EXACT_TOKENS: Readonly<Record<string, string>> = Object.freeze({
paused: 'paused',
stopped: 'paused',
executing: 'executing',
'in progress': 'executing',
'ready to execute': 'executing',
planning: 'planning',
'ready to plan': 'planning',
'planning complete': 'planning',
discussing: 'discussing',
verifying: 'verifying',
completed: 'completed',
done: 'completed',
complete: 'completed',
'phase complete': 'completed',
// advance-plan's phase-complete write (state-transition.cts:1812) — maps
// to `verifying`, preserving the pre-#4186 branch order where `verif`
// outranked `complete`.
'phase complete — ready for verification': 'verifying',
'all phases complete': 'completed',
// Legacy bare terminal form. ADR-2207/#2204 removed it from every WRITER
// (phase verbs write `All phases complete`; milestone close writes
// `<version> milestone complete`) — kept here as READER recognition so a
// legacy STATE.md still normalizes, exactly the way KNOWN_TEMPLATE_DEFAULTS
// keeps the other legacy Status strings.
'milestone complete': 'completed',
unknown: 'unknown',
} as const);
/**
* #4186: ANCHORED patterns for handler-written raw statuses whose text
* carries a variable component (a phase number, a milestone version, a
* #1070 completion glyph). Each pattern is matched against the same
* normalized key as `STATUS_EXACT_TOKENS` (lowercased, trimmed,
* whitespace-collapsed) and must match the WHOLE value — never a substring —
* mirroring how `KNOWN_STATUS_PATTERNS` anchors its template-default checks.
* `Executing Phase 5 — final stretch` (executor-appended prose) matches
* NOTHING and passes through verbatim, the same discipline #1070 applies to
* "Complete but needs manual QA".
*/
export const STATUS_ANCHORED_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = Object.freeze([
// begin-phase / planned transitions write `Executing Phase ${N}`.
[/^executing phase\s+\S+$/, 'executing'],
[/^planning phase\s+\S+$/, 'planning'],
[/^verifying phase\s+\S+$/, 'verifying'],
// phase-complete verbs write `Phase ${N} complete` (state.cts) — the exact
// shape the #3578 demote guard then re-checks against the disk counters.
[/^phase\s+\S+\s+complete$/, 'completed'],
// milestoneCompleteCore writes `${version} milestone complete` (terminal,
// ADR-2207); the version label is a single token (milestone.cts's charset
// validation admits letters, digits, '.', '-', '_').
[/^\S+\s+milestone complete$/, 'completed'],
// #1070: LLM executors may write "Complete ✓" or bare "Complete" when
// finishing a phase.
[/^complete\s*[✓✔✅☑]?$/, 'completed'],
] as const);
/**
* Normalize a raw `Status` body-field value to the canonical status token.
*
* #4186: recognition is ANCHORED — the whole field value (lowercased,
* trimmed, whitespace-collapsed) must be a member of the declared vocabulary
* (`STATUS_EXACT_TOKENS` / `STATUS_ANCHORED_PATTERNS` above). The pre-#4186
* implementation ran a first-match-wins chain of SUBSTRING tests over the
* free-prose field, so any prose merely CONTAINING a trigger word was
* silently rewritten to a credible wrong token: a `.planning/...` path
* mentioned in a non-English status line landed on `planning` (the trigger
* word lives in the directory name and outranked the `verif`/`complete`
* branches), Italian `verifica*` landed on `verifying`, `completezza` and
* `fasi complete` landed on `completed`. The lenient FALLBACK is unchanged
* and recorded (#3873 phase-3 row 26): an unrecognized value passes through
* verbatim — visible prose, never a guessed token.
*
* `pausedAt` keeps its documented force (issue #4186: intended behavior): a
* truthy value yields `paused` regardless of the prose.
*/
export function normalizeStateStatus(status: string | null | undefined, pausedAt: unknown): string {
if (pausedAt) return 'paused';
if (!status) return 'unknown';
const key = status.trim().toLowerCase().replace(/\s+/g, ' ');
const exact = STATUS_EXACT_TOKENS[key];
if (exact) return exact;
for (const [pattern, token] of STATUS_ANCHORED_PATTERNS) {
if (pattern.test(key)) return token;
}
return status;
}
/**
* ADR-3180 §7.6 rule 4 (#3217): `scope` is the `listMilestonePhaseDirs`-owner
* discriminator for the phase/plan set these four counts were derived from.
* A caller that cannot vouch for `scope === SCOPE.COMPLETE` must pass the
* scope it actually has — this function refuses to compose a percentage
* from counts whose scope says they are not a trustworthy answer, returning
* `null` (never `0`; see the module's already-existing "no data" `null`
* below, which this generalizes) exactly like its pre-existing "no data"
* case. `scope` is REQUIRED (no default) so a caller cannot silently opt out
* of rule 4 by omission.
*/
export function computeProgressPercent(
completedPlans: number | null,
totalPlans: number | null,
completedPhases: number | null,
totalPhases: number | null,
scope: Scope
): number | null {
if (scope !== SCOPE.COMPLETE) return null;
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
if (!hasPlanData && !hasPhaseData)
return null;
// Use nullish coalescing to avoid non-null assertion operators (flow narrowing
// cannot track through intermediate boolean variables).
const planFraction = hasPlanData ? (completedPlans ?? 0) / (totalPlans ?? 1) : 1;
const phaseFraction = hasPhaseData ? (completedPhases ?? 0) / (totalPhases ?? 1) : 1;
return clampPercentFromFraction(Math.min(planFraction, phaseFraction));
}
export function shouldPreserveExistingProgress(existingProgress: unknown, derivedProgress: unknown): boolean {
if (!existingProgress || typeof existingProgress !== 'object')
return false;
if (!derivedProgress || typeof derivedProgress !== 'object')
return false;
const existing = existingProgress as ProgressRecord;
const derived = derivedProgress as ProgressRecord;
// total_phases (#1446) and total_plans (#2440) are intentionally excluded
// from the ratchet: both must always take the freshly derived value so they
// can correct in BOTH directions. total_plans legitimately moves up (a new
// phase adds plans) and down (milestone reorganization removes phases).
// Ratcheting it freezes stale values. Only completed_phases and
// completed_plans keep ratchet behaviour — they are monotonic (once a
// phase/plan is complete, it stays complete).
return (
existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
existingProgressExceedsDerived(existing, derived, 'completed_plans')
);
}
export function normalizeProgressNumbers(progress: unknown): unknown {
if (!progress || typeof progress !== 'object')
return progress;
const normalized: ProgressRecord = { ...(progress as ProgressRecord) };
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans', 'percent']) {
const number = toFiniteNumber(normalized[key]);
if (number !== null)
normalized[key] = number;
}
return normalized;
}
/**
* KNOWN_TEMPLATE_DEFAULTS — per-field table of string values that were written
* by a GSD handler (not by an executor / human). A value that appears in this
* list is safe to overwrite on the next handler call. Any other value was
* authored by the executor and must be preserved (Knuth invariant:
* handler-owns-transition-between-known-template-defaults).
*
* Keys must match the canonical field name as it appears in STATE.md.
* Comparison is case-insensitive so "None" and "none" both match.
*
* For Status, exact strings are supplemented by a pattern list
* (KNOWN_STATUS_PATTERNS) that matches handler-generated values whose exact
* text is variable (e.g. "Executing Phase 5").
*/
export const KNOWN_TEMPLATE_DEFAULTS: Record<string, string[]> = {
'Resume File': ['None'],
'Status': [
'Ready to execute',
'Phase complete — ready for verification',
'Ready to plan',
'Defining requirements',
'Planning complete',
// Legacy / abbreviated handler values present in older STATE.md files
'Executing',
'In progress',
'Planning',
'Verifying',
'Completed',
'Done',
'Active',
'Paused',
'unknown',
],
// Last Activity is a date field; ISO date-only strings (YYYY-MM-DD) are the
// handler-generated form. We detect them by shape rather than an exhaustive
// list because the date changes every day.
// NOTE: entries here are matched by isStateTemplateDefault using the date regex
// in addition to exact string equality.
'Last Activity': [],
'Last activity': [],
};
/**
* Regex patterns that match handler-generated Status values whose text includes
* a variable component (e.g. phase number). Checked after the KNOWN_TEMPLATE_DEFAULTS
* exact-match list in isStateTemplateDefault.
*/
export const KNOWN_STATUS_PATTERNS: RegExp[] = [
/^Executing Phase\s+\d+/i,
/^Planning Phase\s+\d+/i,
/^Phase\s+\d+\s+complete/i,
/^Verifying Phase\s+\d+/i,
/^Phase complete/i,
// #1070: LLM executors (e.g. OpenCode) may write "Complete ✓" or bare "Complete"
// when finishing a phase. Only bare terminal markers yield to the next phase's
// "Ready to execute" during planned-phase. The pattern is anchored at both ends
// so that statuses with trailing prose (e.g. "Complete but needs manual QA",
// "Complete — ready for verification") are NOT matched and are preserved as
// executor-authored values. Only exact forms like "Complete", "Complete ✓",
// "Complete✓", or "Complete ☑ " (trailing whitespace) match.
/^Complete\s*[✓✔✅☑]?\s*$/i,
];
/**
* Returns true when the given value is a known template default for the field,
* meaning a GSD handler wrote it and a subsequent handler may replace it.
*
* A value is considered a template default when:
* (a) it appears in KNOWN_TEMPLATE_DEFAULTS[field] (exact, case-insensitive), OR
* (b) it matches the ISO date-only shape (YYYY-MM-DD) for Last Activity fields
* (handlers always write bare dates; executors write narrative prose).
*
* @param field - Canonical field name (case-sensitive key lookup attempted
* first, then case-insensitive fallback).
* @param value - The current value extracted from STATE.md.
* @returns boolean
*/
export function isStateTemplateDefault(field: string, value: unknown): boolean {
if (value === null || value === undefined) return true; // absent → initial write
// Narrow to string: callers pass string values extracted from STATE.md.
const v = (typeof value === 'string' ? value : `${value as boolean | number}`).trim();
if (v === '') return true; // blank → treat as absent
// Look up the defaults list, trying exact key first then case-insensitive.
let defaults: string[] | null | undefined = KNOWN_TEMPLATE_DEFAULTS[field];
if (!defaults) {
const fieldLower = field.toLowerCase();
const matchKey = Object.keys(KNOWN_TEMPLATE_DEFAULTS).find(k => k.toLowerCase() === fieldLower);
defaults = matchKey ? KNOWN_TEMPLATE_DEFAULTS[matchKey] : null;
}
if (defaults && defaults.some(d => d.toLowerCase() === v.toLowerCase())) {
return true;
}
const fieldLower = field.toLowerCase();
// Status: also check pattern list for variable handler-generated values
// (e.g. "Executing Phase 5", "Planning Phase 3").
if (fieldLower === 'status') {
if (KNOWN_STATUS_PATTERNS.some(p => p.test(v))) return true;
}
// Last Activity / Last activity: bare ISO date (YYYY-MM-DD) is handler-generated.
if (fieldLower === 'last activity') {
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return true;
}
return false;
}
/**
* Replaces a field in STATE.md content only when the existing value is a known
* template default (or the field is absent). If the existing value is
* executor-authored, the content is returned unchanged.
*
* When `newValue` is null or undefined the function is a no-op (returns content).
*
* @param content - Full STATE.md text.
* @param field - Field name as it appears in STATE.md.
* @param knownDefaults - The defaults list to check against (typically
* KNOWN_TEMPLATE_DEFAULTS[field]).
* @param newValue - Value to write when replacement is permitted.
* @returns Updated content (or original if skipped).
*/
export function stateReplaceFieldIfTemplate(content: string, field: string, knownDefaults: string[] | null | undefined, newValue: string | null | undefined): string {
if (newValue === null || newValue === undefined) return content;
const existing = stateExtractField(content, field);
// Inline check: absent/blank → always write; in list → write; else → skip.
if (existing === null || existing === undefined || existing.trim() === '') {
return stateReplaceField(content, field, newValue) || content;
}
const v = existing.trim();
const inList = (knownDefaults || []).some(d => d.toLowerCase() === v.toLowerCase());
const fieldLower = field.toLowerCase();
// Special-case: Status pattern list for variable handler-generated values.
const matchesStatusPattern = (fieldLower === 'status') && KNOWN_STATUS_PATTERNS.some(p => p.test(v));
// Special-case: Last Activity bare ISO date (YYYY-MM-DD) is handler-generated.
const isDateShape = (fieldLower === 'last activity') && /^\d{4}-\d{2}-\d{2}$/.test(v);
if (inList || matchesStatusPattern || isDateShape) {
return stateReplaceField(content, field, newValue) || content;
}
// Executor-authored — preserve.
return content;
}