refactor(#3187): give state field extraction one canonical owner (#3283)

* refactor(#3187): give state field extraction one canonical owner

stateFieldValue in state-document.cts becomes the single owner of the #1760
frontmatter-then-body fallback chain. The new whole-repo guard found 14
independent re-derivations where the epic scoped 5, all now routed through it:
cmdStateSnapshot (11), cmdStatePrune (2) and smart-entry fmScalar (1).

state validate was a gate that could not fail. Every warning it could emit sat
behind a phase resolved without the frontmatter tier, so a STATE.md whose phase
lives only in frontmatter skipped the drift scan entirely and returned
valid:true. It also read unstripped content, letting a frontmatter status: key
shadow the body field (#1255 class). Both fixed; output gains a scope field so
could-not-look stops being output-identical to looked-and-clean.

Verified on the remote runner.

* docs(#3187): document the state validate scope field and its reason codes

Adds docs/how-to/interpret-state-validate-results.md so a reader can tell
nothing-to-report from could-not-look, updates the COMMANDS.md and USER-GUIDE.md
entries, corrects the CONTEXT.md glossary overstatement about Current Position
sole ownership, and drops the changeset fragment.

* fix(#3187): close three drift-guard evasion shapes and test the refuse path

The isolated adversarial review found the ladder detector was evadable by
ordinary reformatting, not just deliberately: a member or computed operand
(fm.key / fm[key]) missed the bare-identifier backreference, a swapped tier
order missed a hardcoded number-then-boolean sequence, and a ladder wrapped
across lines missed single-line detection. All three now caught, each with its
own test plus a proven boundary control.

The frontmatter-parse refuse path on the destructive complete-phase route was
unreachable and therefore untested. It is now driven by an injected parse
failure and asserts STATE.md is byte-identical after the refusal, rather than
shipping untested defensive code on a path that rewrites user state.

Verified on the remote runner.

* fix(#3187): widen the drift guard to the prompt layer and disclose tier-2 changes

The code-review spec axis found the guard's scan surface was src/ only, which is
Decision 4(d)'s forbidden allowlist one directory wide - and it had a live miss:
gsd-core/workflows/smart-entry.md tells an agent to read status from frontmatter
or the body, a prose expression of this same chain. The surface now covers the
prompt layer. That one site carries a permanent written exemption rather than a
ratchet: it is the gsd-tools-is-down fallback, so it cannot call the owner by
construction, and a ratchet would imply removable debt that does not exist.

Two tier-2 output changes shipped undisclosed and are now named in the changeset
and docs: complete-phase's idempotency guard consulting frontmatter, and the
workstream inventory resolving frontmatter-only fields. docs/COMMANDS.md gains a
state complete-phase entry, which it never had.

Also records Amendment 5 on ADR-3180, extracts the duplicated frontmatter-parse
block the epic's own thesis forbids, and re-points two assertions from free-form
warning prose onto the structured drift object.

Verified on the remote runner.

* chore(#3187): backfill changeset PR number

pr:0 placeholder replaced with the real PR number now that #3283 exists.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 22:49:39 -04:00
committed by GitHub
parent cf6de5e1c0
commit 693f12ad56
16 changed files with 2616 additions and 80 deletions

View File

@@ -11,6 +11,10 @@ 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 {
@@ -234,6 +238,80 @@ export function stateExtractField(content: string, fieldName: string): string |
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