* 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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
57
docs/how-to/interpret-state-validate-results.md
Normal file
57
docs/how-to/interpret-state-validate-results.md
Normal 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)
|
||||
Reference in New Issue
Block a user