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

@@ -1770,6 +1770,17 @@ Detect drift between STATE.md and the actual filesystem.
node gsd-tools.cjs state validate
```
The report also carries a `scope` field reporting whether the drift derivation could actually run:
| `scope` | Meaning |
|---|---|
| `complete` | The derivation ran over usable input — a resolvable phase, a readable disk scan. `valid`/`warnings`/`drift` are a real answer. |
| `truncated` | Part of the input was cut short (e.g. the phase's plan/summary scan hit its cap) — the answer may be incomplete. |
| `unscoped` | `Current Phase` could not be resolved from either frontmatter or body — there was nothing to scope the disk lookup to, so the derivation never ran. |
| `unreadable` | The frontmatter parse or a filesystem read (the phases directory scan) failed — the derivation could not consult its input. |
`valid` is **not** routed from `scope`: `valid` still means "no drift warnings were found," and `scope` says whether the scan could actually run. A freshly-initialized project reports `{valid:true, warnings:[], drift:{}, scope:'unscoped'}` — nothing was wrong, and the phase could not be checked. See [Interpret `state validate` results](how-to/interpret-state-validate-results.md) for how to act on each `scope` value.
---
### `state sync [--verify]`
@@ -1830,6 +1841,21 @@ node gsd-tools.cjs state planned-phase --phase 3 --plans 2
---
### `state complete-phase [--phase N]`
Mark the current phase as COMPLETE in STATE.md — updates the body `Status`, `Last Activity`, and `## Current Position` fields. `--phase` is optional; when omitted, the phase is resolved from STATE.md's `Current Phase`/`Phase` fields (frontmatter `current_phase` preferred, falling back to the body).
**Idempotency guard (#3489):** if STATE.md's canonical current phase already names a phase distinct from the one being marked complete — including when that phase lives only in frontmatter `current_phase`, not the body — the command is a no-op (`idempotent: true`) rather than rolling STATE.md back to the requested phase's moment-of-completion. If the frontmatter cannot be parsed at all, the command refuses outright (`Unable to read STATE.md frontmatter; refusing to run complete-phase to avoid a destructive rollback`) instead of guessing.
**Prerequisites:** `.planning/STATE.md` exists
**Produces:** Updated `STATE.md` marking the resolved phase complete, or a no-op when the guard determines the phase was already superseded
```bash
node gsd-tools.cjs state complete-phase --phase 3
```
---
## Community Commands
### Community Hooks

View File

@@ -36,6 +36,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Isolate work with workspaces](how-to/isolate-work-with-workspaces.md) — use workspaces to sandbox experimental or risky changes
- [Debug a failed execution](how-to/debug-a-failed-execution.md) — diagnose and recover from broken or incomplete phase execution
- [Interpret scope-conformance warnings](how-to/interpret-scope-conformance-warnings.md) — read the advisory the worktree-wave merge emits when a plan branch commits outside its declared scope
- [Interpret `state validate` results](how-to/interpret-state-validate-results.md) — read the `scope` reason codes and tell "nothing to report" apart from "could not look"
- [Spike and sketch](how-to/spike-and-sketch.md) — use `/gsd-spike` and `/gsd-sketch` for exploratory work before committing to a plan
- [Design a UI phase](how-to/design-a-ui-phase.md) — use the UI phase loop for frontend and visual work
- [Develop a Capability for GSD 1.5+](how-to/develop-a-capability.md) — add feature Capabilities, hook fragments, and registry entries

View File

@@ -644,6 +644,8 @@ node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" state sync --verify # Previe
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" state sync # Reconstruct STATE.md
```
`state validate`'s report carries a `scope` field alongside `valid` — `valid:true` means no drift was found, but `scope` says whether the check could actually run at all (a phase that could not be resolved, or an unreadable frontmatter/phases directory, reports `valid:true` too, because there was nothing to flag). See [Interpret `state validate` results](how-to/interpret-state-validate-results.md) before treating a passing `state validate` as "clean."
### A Command Looks Frozen After "Spawning..."
GSD subagents run in a separate context window — their work is invisible to the parent session while in progress. Do not interrupt the session. Wait for the result; research and planning agents routinely take 1–5 minutes.
@@ -967,7 +969,7 @@ To disable parallel execution entirely: `/gsd-settings` → set `parallelization
| Phase went wrong | `git revert` the phase commits, then re-plan |
| Need to change scope | `/gsd-phase` (default), `/gsd-phase --insert`, or `/gsd-phase --remove` |
| Something broke | `/gsd-debug "description"` (add `--diagnose` for analysis without fixes) |
| STATE.md out of sync | `state validate` then `state sync` |
| STATE.md out of sync | `state validate` then `state sync` — check the report's `scope` field, not just `valid` ([interpret results](how-to/interpret-state-validate-results.md)) |
| Workflow state seems corrupted | `/gsd-forensics` |
| Quick targeted fix | `/gsd-quick` |
| Plan doesn't match your vision | `/gsd-discuss-phase [N]` then re-plan |

View File

@@ -739,3 +739,75 @@ out, and an incomplete list is the same defect in miniature that this epic exist
as a *named canonical function* — the only sanctioned exemption form. `locateMilestoneHeadings` was
refactored into a version-filtered view over one shared grammar source so the two primitives cannot
drift, with a parity test asserting the filtered enumeration equals the locator's selection.
### Amendment 5 — Phase 5 (#3187) validation: the contract held; the guard nearly reported a zero it had not earned
Decision 2's contract needed **no change** for its fifth consumer. `stateFieldValue` returns
`{ value, scope }` over the frozen four-member `SCOPE`, and every case this phase hit was already
one of them. `src/planning-scope.cts` is untouched, so Phase 5 carries no `.cts` six-gate ripple.
**Declared deviation from Decision 1's owner surface.** Decision 1 names the owner as
`stateExtractField` "carrying the #1760 fallback chain". Shipped instead: `stateExtractField` is
left **byte-identical** and the chain is a *new* owner beside it. `stateExtractField` answers a
narrower question — "what is field F in this body text" — which genuinely has no scope dimension,
and it has **20 direct callers with a CRITICAL blast radius** (53 affected symbols, 5 process
flows). Putting the scope on the primitive would have rewritten every one of those call sites to
buy nothing. The thing that can fail to run is the *chain*, so the chain is what carries the scope.
This is a signature deviation, not a contract change — the same class Amendment 3 declared for
`listMilestonePhaseDirs`.
**The copy count was a lower bound for the SIXTH consecutive time — 14, where the epic scoped 5.**
Per Amendment 3's standing rule the guard was built and run **before** scope was fixed. Grouped by
ladder-bearing function: `cmdStateSnapshot` 11, `cmdStatePrune` 2, `smart-entry.cts::fmScalar` 1.
`find_symbol` separately reports 20 direct callers, which is the figure §7.7 already told Phase 5 to
drive from rather than the epic's "five call sites".
**The first guard would have lied, and the mechanism is worth recording because it is new.** A
bounded-line-window detector was built first and found **7**. The other **7** —
`state.cts:1484–1497` — sat further from `cmdStateSnapshot`'s ladder than its 15-line window
reached. Migrating the visible 7 would have left a green guard with seven survivors: Decision 4(a)'s
"a zero it did not earn", arrived at by a mechanism no prior phase met. Replaced with
**function-scoped co-occurrence** — *a function that both reads a frontmatter scalar and calls the
body extractor is re-deriving the chain* — which needs no threshold constant and cannot be reflowed
around. **Generalized lesson: any numeric window in a drift guard is a Goodhart target; scope
detection to a syntactic unit instead.** Two further evasions (a member/computed operand, a swapped
tier order) were found by the isolated review and closed with their own tests; a guard is not
trusted until a deliberate re-derivation is shown to fail it.
**Decision 4(d) applied to this derivation, and it bit again.** The guard's first scan surface was
`src/` — which is exactly the one-directory-wide allowlist 4(d) was written to forbid, repeated by a
phase that had read 4(d). `gsd-core/workflows/smart-entry.md` instructs an agent to read `status`
from "frontmatter `status:` or body `**Status:**`" — a prose expression of this chain. The surface
is now scanned. That site carries a **permanent exemption with a written reason**, not a Decision
4(e) ratchet: it is the `gsd-tools`-is-down fallback, so by construction it cannot call the owner,
and a ratchet implies removable debt with an owning issue. **Not every unconsolidated site is
debt** — 4(e) governs debt, and a documented impossibility is a different thing.
**A seventh derivation family was found and deliberately NOT consolidated, on evidence.** Three
`## Current Position` locators exist. `state.cts::matchCurrentPositionSection` genuinely delegates
to `state-document.cts::stateCurrentPositionSlice`, so it is not a copy. But
`state-transition.cts::locateCurrentPosition` / `sliceCurrentPositionSection` is independent, and
both were run over eight adversarial bodies. Fence-awareness turned out to be **shared** (both reach
`tokenizeHeadings`), so that concern was unfounded — but the two diverge in *both* directions:
`locateCurrentPosition` misses `### Current Position` at h3, while `collectSection` applies
`.trimEnd()` where the byte-exact mutation callers (`mutateCurrentPositionFirstTime` /
`mutateCurrentPositionResume`) depend on the trailing newline for span reassembly. Folding would
silently drop a newline from every Current Position body on every write. **This is Amendment 3's
"0.x split" recurring: one owner governs who computes an answer, not how many questions share it.**
Left for its own phase; `CONTEXT.md`'s claim that the read-path slice is "the one owner of that
scope" is corrected to name the mutation-path locator.
**Decision 3's Tier-2 table, re-derived for Phase 5:**
| Command surface | Output change |
|---|---|
| `state validate` | gains `scope`. `valid` is **not** routed from it — Decision 2 rejected a boolean ok/degraded, and a legacy STATE.md with no `## Current Position` is `UNSCOPED`, a supported degrade, not an invalid document. It also stops resolving its phase from unstripped content, closing a #1255-class frontmatter shadowing where a frontmatter `status:` key won over the body field |
| `state complete-phase` — **not predicted** | the #3489 idempotency guard now consults frontmatter `current_phase`, so a project whose phase lives only in frontmatter is no longer silently rolled back on a re-run; adds a refusal when frontmatter cannot be read |
| `workstream` inventory — **not predicted** | `readStateProjection` resolves frontmatter-only `status`/`current_phase`/`last_activity` instead of reporting them absent |
| `state snapshot`, `state prune`, `/gsd:next` signals | resolve fields through the one owner, so they can no longer disagree about the same STATE.md. `smart-entry`'s read stays deliberately **unscoped** under a written exemption — it asks a different question than `state.cts`'s #1776/#2956-scoped copies, and folding it would silently change routing |
**What this phase does NOT close.** §7.7's rule says `state validate` "reports invalid for a
genuinely invalid document" but never defines *genuinely invalid*. This phase does not invent that
definition — it makes the derivation's scope visible so the question becomes answerable, and emits
the drift warnings that were always intended but unreachable. Widening what counts as invalid stays
undecided, per §7's own rule.

View File

@@ -0,0 +1,57 @@
# How to interpret `state validate` results
**Goal:** Read a `state validate` report correctly — distinguish *"nothing to report"* from *"could not look"*, so a passing `valid:true` is never mistaken for a guarantee the check actually ran.
**Prerequisites:** A `.planning/STATE.md` file. Run the check with:
```bash
node gsd-tools.cjs state validate
```
For the flag/output reference, see [`state validate`](../COMMANDS.md#state-validate). This guide covers only how to *read and act on* the result.
---
## Read the two fields separately
`state validate` returns two fields that answer two different questions, and conflating them recreates the exact defect (#3162) this command's `scope` field exists to prevent:
| Field | Question it answers |
|---|---|
| `valid` | "Did the drift scan find any problems?" — `true` means **no warnings were raised**. |
| `scope` | "Could the drift scan actually run?" — reports whether the check had what it needed to look. |
`valid` is **never** derived from `scope`. A document the scan could not check at all (`scope` other than `complete`) still reports `valid:true`, because zero warnings really were raised — there just wasn't anything to raise them from. Reading `valid:true` alone as "STATE.md is clean" is the trap: always check `scope` first.
---
## The `scope` reason-code table
| `scope` | What it means | What caused it | What to do |
|---|---|---|---|
| `complete` | The derivation ran over usable input — a resolvable `Current Phase` and, if a matching phase directory exists, a readable disk scan of it. `valid`/`warnings`/`drift` are a real, trustworthy answer. | Normal operation: STATE.md's phase resolved (from frontmatter or body) and the filesystem was readable. | Trust the result as-is. If `valid:false`, act on the listed `warnings`/`drift` entries (typically `state sync`). |
| `truncated` | Part of the input was cut short before the scan finished — the phase directory's plan/summary scan hit an internal cap partway through. The `valid`/`drift` answer may be **incomplete**, not necessarily wrong. | An unusually large phase directory (many plan/summary files) exceeded the scan's bounded window. | Do not treat `valid:true` here as a clean bill of health. Inspect the phase directory directly (`ls .planning/phases/<phase>/`) to confirm counts by hand, or reduce/split the phase's plan set if this recurs. |
| `unscoped` | `Current Phase` could not be resolved from **either** the frontmatter scalar or the body field — there was no phase to scope the disk lookup to, so the drift derivation never ran at all. | Most commonly a freshly-initialized project with no phase set yet (a genuine, supported state). Less commonly, a STATE.md whose `Current Phase` field was dropped or malformed. | If the project has not started a phase yet, this is expected — no action needed. If the project is active and you expect a phase to be set, open STATE.md and check the `current_phase` frontmatter key and the body's `**Current Phase:**` row; run `state sync` to reconstruct it from disk if it is missing. |
| `unreadable` | An input the scan needed could not be consulted at all — either the frontmatter block failed to parse, or a filesystem read (the phases directory scan) failed mid-scan. | An unterminated/malformed YAML frontmatter fence, or a filesystem error (permissions, a race with a concurrent write) while reading `.planning/phases/`. | Treat `valid:true` here as **not trustworthy** — the scan degraded silently before this field existed, and now surfaces that instead of hiding it. Check that STATE.md's frontmatter fence (`---` / `---`) is well-formed, and that `.planning/phases/` is readable by the current user. Re-run `state validate` after fixing either. |
---
## The case this exists for: "`valid:true` — but is it trustworthy?"
If you only ever read `valid`, every one of the four `scope` values above looks identical: `true`. That collapse is the exact bug this field was added to close (#3162) — a STATE.md whose phase lived only in frontmatter used to silently skip the entire drift scan and report `{valid:true, warnings:[], drift:{}}`, indistinguishable from a phase that was checked and found clean.
So before trusting a green `state validate`, always inspect `scope`:
- **`scope:'complete'`** — trustworthy. The scan ran; `valid:true` means clean.
- **Anything else** — not yet checked, or only partially checked. `valid:true` here means *"no problems were found in what could be looked at,"* which is a materially weaker claim. Use the table above to find out why, and whether that is expected (a fresh project, `unscoped`) or a problem worth fixing (`unreadable`, or a `truncated` scan on a large phase).
A freshly-initialized project is the clearest example of a **legitimate** non-`complete` scope: it reports `{valid:true, warnings:[], drift:{}, scope:'unscoped'}`, which reads as *"nothing was found wrong, and the phase could not be checked"* — not as a defect to fix.
---
## Related
- [`state validate`](../COMMANDS.md#state-validate) — command reference, flags, and the full output shape
- [`state sync`](../COMMANDS.md#state-sync---verify) — reconstructs STATE.md from disk when drift or a missing `Current Phase` is found
- [Recover and troubleshoot](recover-and-troubleshoot.md) — broader STATE.md recovery guidance
- [docs index](../README.md)