* fix(#3374): phase.complete stops harvesting stale body stopped_at Variant A: cmdPhaseComplete's adapter calls syncStateFrontmatter directly (deliberately - STATE.md commits atomically with ROADMAP/REQUIREMENTS), which also bypassed the #948/#1230 preservation pass every RMW write gets. A stale body 'Stopped at:' line then silently clobbered a fresher frontmatter stopped_at on every phase completion, with warnings: []. Three layers close it without reversing #3517's refresh expectation: - completePhaseCore now refreshes the body continuity line it implies ('Phase N complete, ready to plan Phase N+1'; ADR-2207 phrasing on the last phase), session-scoped via the new stateReplaceFieldInSession seam so a decoy bold Stopped-at line in an unrelated section cannot absorb the refresh. Replace-only - a layout with no session line keeps its shape and its frontmatter value survives via the preservation delta. - the RMW post-sync preservation chunk (snapshots + table-driven applyStatePreservation + #2736 re-assert, full bodyDeltas wired) is extracted into the shared applyPostSyncPreservation helper; the phase.complete adapter and writeStateMd (milestone complete / state sync - the gap the closed PR #3442 review flagged) now run it too. - cmdStateRecordSession pushed 'Stopped At' onto updated[] on any label MATCH, including a value already on disk - reporting a write that never changed a byte. It now reports only on real change, and the match is tracked separately so an identical value does not arm the #944 DWIM section rewrite (which would reset an executor-authored resume file to None). * docs(#3374): backfill changeset pr field to 3491 * fix(#3374): drop the writeStateMd preservation pass - state sync's #905 contract is body-wins CI on this PR caught what the closed PR #3442 review's MAJOR remediation option (a) would have broken: state sync's #905 contract ('body annotation beats existing frontmatter when both are present') is the opposite by design - sync exists to re-derive frontmatter from the body. A blanket applyStatePreservation pass on writeStateMd re-locked stale frontmatter (current_phase 3 over the body's 5) on every sync. Take the review's sanctioned option (b) instead: the scope claim is accurate (phase.complete only) and the milestone complete / state sync exposure is tracked as follow-up issue #3492. --------- Co-authored-by: sim <sim@local>
638 lines
30 KiB
TypeScript
638 lines
30 KiB
TypeScript
/**
|
||
* 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;
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
|
||
const escaped = escapeRegex(fieldName);
|
||
// Bold inline format: **FieldName:** value
|
||
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
|
||
if (boldPattern.test(content)) {
|
||
return content.replace(boldPattern, (_match, prefix: string) => `${prefix}${newValue}`);
|
||
}
|
||
// Plain line-start format: FieldName: value
|
||
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
|
||
if (plainPattern.test(content)) {
|
||
return content.replace(plainPattern, (_match, prefix: string) => `${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));
|
||
}
|
||
|
||
export function normalizeStateStatus(status: string | null | undefined, pausedAt: unknown): string {
|
||
let normalizedStatus = status || 'unknown';
|
||
const statusLower = (status || '').toLowerCase();
|
||
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
|
||
normalizedStatus = 'paused';
|
||
}
|
||
else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
|
||
normalizedStatus = 'executing';
|
||
}
|
||
else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
|
||
normalizedStatus = 'planning';
|
||
}
|
||
else if (statusLower.includes('discussing')) {
|
||
normalizedStatus = 'discussing';
|
||
}
|
||
else if (statusLower.includes('verif')) {
|
||
normalizedStatus = 'verifying';
|
||
}
|
||
else if (statusLower.includes('complete') || statusLower.includes('done')) {
|
||
normalizedStatus = 'completed';
|
||
}
|
||
else if (statusLower.includes('ready to execute')) {
|
||
normalizedStatus = 'executing';
|
||
}
|
||
return normalizedStatus;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|