* wip(#3217): rule-4 scope withholding — parked, two open findings Implemented but NOT shippable. An isolated review found buildStateFrontmatter still hardcodes SCOPE.COMPLETE, so state json reports percent 0 where roadmap analyze, stats and query progress all correctly report null on the same disk state - rule 4 reintroduced at a site this phase claims to close. Also: roadmap analyze emits scope complete beside progress_percent null with nothing explaining it. Parked to build Phase 4 (#3186) first, which is unblocked. Findings recorded in .gsd/phase/refactor-3217-completion-ratio-scoping/60-review.json. * fix(#3217): withhold the sync percentage on a non-complete scope The parked blocker is fixed - buildStateFrontmatter no longer hardcodes SCOPE.COMPLETE, and the prose Progress fallback is gated too, which was a second leak found while tracing the first. roadmap analyze exposes progress_scope so a consumer can tell WHY a percentage is absent from the JSON alone. Then a residual gap was reproduced rather than assumed. cmdStateSync carried the same hardcode behind a written reason claiming it did not reproduce. It did: on a TRUNCATED window and on UNSCOPED row 4, state sync wrote Progress 0 percent to 100 percent while state json, roadmap analyze, stats and query progress all withheld - and it persisted a self-contradictory file, body claiming 100 percent while its own frontmatter correctly omitted percent. The excuse was also wrong. syncRoadmapRaw is already parsed in that function and is exactly what produces a real scope, so there was a scope to pass. Threaded through listMilestonePhaseDirs; a non-complete scope now skips the write with a reason in changes. milestoneBounded stays as the orthogonal 1761 guard for row 5. Second time this epic a does-not-reproduce claim was too generous. Recorded in ADR Amendment 8 as a correction rather than a quiet rewrite. Verified on the remote runner. * test(#3217): give the withholding fixtures a resolvable scope 40 matrix failures, all fixture drift - no code regression. My own hypothesis that this was over-withholding was wrong and is recorded as such: the worry case, a plain ROADMAP with Phase entries and no version heading, resolves to complete exactly as ADR 7.1 says it should. The real causes were two fixture shapes. Most had no ROADMAP.md at all, which is unreadable via a pre-existing graceful path, and asserted a numeric percent. The five vscode, pi-extension, mcp-server and shell-projection failures were that shape - bare temp dirs using progress json as a reachability proxy while asserting typeof percent is number, which under rule 4 is now null. The rest had a version token in a title or heading with no STATE.md milestone pointer to resolve it, which is classification row 4, versioned but unresolved, so withholding is correct per the contract. Verified on the remote runner. * test(#3217): make the LM-tools reachability tests dispatch against their fixture The gsd_progress reachability test was never testing its fixture. invoke() resolves cwd from vscode.workspace.workspaceFolders by design (the real LanguageModelToolInvocationOptions has no cwd field, per the 2103 fix in extension.js), the mock had no workspace at all, and the test passed a cwd option nothing reads - so it dispatched against the repo working directory. Writing a ROADMAP into the temp dir had no effect. Rule 4 only made it visible. Fixed by mocking workspaceFolders. The two siblings in the same file carried the identical dead cwd and were dispatching against the repo too; they were not failing only because their assertions did not touch scope-dependent output. Both now use their own fixture with assertions unchanged - the no-planning fallback paths already satisfy them honestly. Re-scanned the other five reachability files: no further instances. They thread cwd into parameters that genuinely read it, not through an options shape that ignores it. Verified on the remote runner. * chore(#3217): backfill changeset PR number pr:0 placeholder replaced with the real number now that #3318 exists. * ci(#3217): give the coverage merge enough heap for the merged shards The coverage gate OOMed at exit 134. c8 report merges three shard artifacts, roughly 358MB of V8 dumps in coverage/tmp, and died holding their per-file position maps at the ~4GB default heap. Verified as this branch's delta rather than pre-existing: the same job succeeded on next at 14:18, after phases 4 and 5 merged. Both coverage-gate steps get the bump because both re-slice the same merged data. 8192 doubles what failed and leaves headroom on a 16GB ubuntu runner, matching the idiom the shard step already uses at 6144. This is a memory bound, not a change to what is measured. No threshold was touched. The test file was checked for gratuitous subprocess spawning and is already reasonable at 43 spawns, each a distinct fixture-by-surface pairing. Verified on the remote runner. --------- Co-authored-by: sim <sim@local>
614 lines
28 KiB
TypeScript
614 lines
28 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 } from './markdown-sectionizer.cjs';
|
||
import type { HeadingToken } from './markdown-sectionizer.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;
|
||
|
||
// Internal helpers
|
||
function escapeRegex(str: string): string {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|