* docs(#3812): say that Current Position is single-valued, and pin the behavior that makes it true #3812 shipped CLOSED with half its acceptance unmet. #3873 delivered cardinality for FRONTMATTER keys - current_phase/current_plan render as optional at docs/reference/state-md.md:89,91, covered by tests/gen-state-md-docs.test.cjs:374. The issue's actual ask was the ## Current Position BODY section, and that never landed. Surfaced by an /adr-phase-coverage audit of epic #3473; the issue was reopened rather than noted. The section now states three things: every field is single-valued, the section is overwritten rather than appended to, and a duplicate resolves to the FIRST occurrence with no warning - so a line appended in good faith is silently ignored rather than winning. Progress history belongs in ## Performance Metrics, two headings down, and the text now points there. The third claim is a behavioral promise about the reader, so it was VERIFIED BY EXECUTION before being written rather than inferred from the issue title: stateExtractField(<"Phase: 1 of 5 (First)" ... "Phase: 9 of 9 (Appended later)">, "Phase") -> "1 of 5 (First)" The mechanism is state-document.cjs:405 - the plain-line pattern ^<field>:[ \t]*(.+) carries flags im with NO g, so String.match returns the first hit. Writing "first wins" without running it would have repeated the exact error I had to retract twice in this epic already. A test pins the reader, not the prose. Three rows in tests/state.test.cjs: T1 (load-bearing) asserts the duplicated case resolves first; T2 asserts the ordinary single-field case still works, so a fix that only functions when duplicated cannot pass; T3 puts a Plan: line BETWEEN the two Phase: lines and asserts it resolves independently - negative space, because a reader returning the first line of the SECTION rather than the first matching FIELD would satisfy T1 alone. Proven to discriminate: a last-match variant returns "9 of 9 (Appended later)" and T1 reds. No assertion checks that the document contains a sentence. That is what local/no-source-grep exists to stop, and it would pin wording that is allowed to improve. The point of the test is that if that regex ever gains g and a last-match walk, the test fails - instead of the documentation quietly becoming a lie with nothing to notice. Prose only, no new heading. docs-state-md-locale-parity compares heading-level sequences by LCS rather than text, so added paragraphs cannot fail it while an added HEADING would fail all four locales. The constraint is structural, not stylistic - confirmed by running that comparison after the edit. The four locale copies are translated rather than left stale. They are not gate-enforced for prose, so "nothing fails" was available and is not the same as correct: leaving four documents asserting something the English one now contradicts is a correctness problem. Code spans and the anchor link stay untranslated - they name real tokens. The whole approach rests on one fact, checked first: ## Current Position at :196-208 sits OUTSIDE every generated marker region (:81-104, :138-151), so a hand edit survives --write. Re-confirmed after all five edits - gen-state-md-docs --check reports all 6 targets up to date. Had that been false the fix would have belonged in the generator, and a hand edit would have been silently reverted. One real gate failure fixed inline rather than reported: the new test's comments referenced docs/reference/state-md.md, which was not in that file's registered exempt-docs paths, and lint-docs-guard-registration failed lint:ci correctly. Registered. Known limit, named rather than folded in: gsd-tools validate/health still do NOT warn on a duplicated Phase:. #3812 records that as a "consider", not a requirement, and confirms none of the nine rules in src/health-diagnostic-rules/{state-consistency,phase-structure}.cts counts occurrences. Documenting the silent first-match is the delivered scope; making it loud is new scope and stays unclaimed. Closes #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3812): the rule I documented was false — replace it with the measured one An isolated review returned two blockers. Both mine, and the first is the worse kind: I wrote a falsifiable rule into a reference page and got it wrong. 1. "A duplicate resolves to the FIRST occurrence" is FALSE. stateExtractField (src/state-document.cts:401-419) tries BOLD `**F:**` across the whole input, THEN plain `^F:`, THEN a pipe-table row. Form precedence beats document order. Measured against the built reader, all intra-section: Phase: A (plain) / **Phase:** B (bold, later) -> B LATER WINS Phase: A (indented) / Phase: B (plain, later) -> B LATER WINS Phase: A (plain) / | Phase | T (table) | -> A first wins My original verification tested plain-versus-plain, saw first-wins, and generalized to all forms. Measuring one case and claiming the general rule is the same error I have had to retract twice already in this epic. It is also worse than silence. The sentence told authors an appended line is safely ignored; a bold line appended "for emphasis" silently overrides the original. Someone trusting the doc would have corrupted their own state file. And #3812 never asked for a resolution rule - it asked for single-valued, overwrite-not-append, and where history goes. The rule was my unrequested addition. Replaced with the measured truth: resolution is by FORM (bold anywhere, then plain at line-start, then table row), and only WITHIN the winning form does the first occurrence win. Both consequences stated plainly - a higher-ranked form wins regardless of position, and an indented `Phase:` is invisible to the plain form. All five claims in the new paragraph verified by execution before being written, including the two I had wrong. 2. The tests tested the wrong case and passed for the wrong reason. T1/T3 put the second `Phase:` under `## Somewhere else` - the INTER-section case, which #2956 already fixed by scoping. #3812 says verbatim that #2956 "fixed the inter-section case and never addressed intra-section duplication", so the case the new prose describes was untested, and the fixtures passed because of section scoping rather than field resolution. They also called bare stateExtractField rather than the production chain, T2 could not discriminate first from last at all, and no fixture mixed forms - which is precisely why the false claim survived to review. Rewritten as four rows, all intra-section, all through the real stateCurrentPositionSlice -> stateExtractField path: plain-then-plain (first wins within a form), plain-then-bold (the bold LATER value wins - the row whose absence let the false claim ship), indented-then-plain (indented invisible), and sibling-field independence. Each proven to fail against a reader that disagrees. 3. Two dead anchors. pt-BR and zh-CN linked `#performance-metrics` while their own headings are `### Métricas de Desempenho` and `### 性能指标`. Both fixed to the anchor their own heading generates. ja-JP/ko-KR kept the English heading, so theirs already resolved. 4. A ja/ko sentence inverted its own meaning. Both rendered "which is the section designed to grow" with a bare demonstrative whose nearest referent read as Current Position - saying the opposite of the point. Rewritten so the clause attaches unambiguously to `## Performance Metrics`. 5. Cross-locale drift, flagged by the implementing agent rather than by me: after fixing EN, the four locales still stated the OLD false rule. Four documents asserting something measured to be wrong is worse than four saying nothing. All four now carry a faithful translation of the corrected paragraph, with code spans, each file's own anchor, and the ja/ko referent fix preserved. Verified: all five claims executed against the built reader; every rewritten test row proven to discriminate; gen-state-md-docs --check reports all 6 targets up to date, so the edits stay outside the generated marker regions; locale heading parity unaffected (prose only, no headings added); build:lib, lint and lint:ci all exit 0. Refs #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3812): second false rule on the same page — scope the ranking to the section A second isolated review found a second false falsifiable claim, and the failure mode is the same one twice in a row: attempt 1: verified plain-vs-plain, wrote a claim about ALL FORMS attempt 2: verified bare stateExtractField, wrote a claim about THE DOCUMENT Both times the claim covered a wider surface than what was actually executed. The fix each time was not a better sentence, it was executing the surface the sentence describes. BLOCKER — "bold `**Phase:**` anywhere in the DOCUMENT wins" is false. ## Current Position / Phase: 1 of 5 + ## Archive / **Phase:** 88 bare stateExtractField(whole doc) -> "88 (other section)" PRODUCTION (slice then extract) -> "1 of 5 (in section)" #2956's section slice means production never hands another section to the matcher; a bold line in `## Archive`, or in the YAML frontmatter, is simply not seen. The ranking is real but scoped: it applies WITHIN `## Current Position`. I verified against the bare function and wrote a claim about the system. Every existing test placed its bold line inside the section, which is exactly why nothing contradicted the claim. T5 now puts a bold `**Phase:**` in `## Archive` and asserts production returns the in-section plain value, with the unscoped reader asserted to DISAGREE so the row proves the scoping rather than assuming it. BLOCKER — the changeset still shipped the ORIGINAL retracted claim. I corrected the page and left the release note saying "resolves to the first occurrence ... a second entry added in good faith is silently ignored". The note contradicted the page it announces, and the release note is what most people actually read. Rewritten to the corrected rule. MEDIUM — the concession was inverted. It read "wins even if it comes FIRST in the file", which is the vacuous direction; the surprising case, and the one the very next clause illustrates with an APPENDED bold line, is "even if it comes LAST". All four locales reproduced the inversion faithfully, so it was an EN-source defect rather than translation drift. Two sharp edges now named, both measured: a bold `**Phase:**` followed only by trailing spaces resolves to an EMPTY STRING and does not fall through to a valid plain line below (T6 pins it); and `| **Phase:** | 3 of 4 |` short-circuits to the bold form and returns the literal `"| 3 of 4 |"`. A page that teaches form ranking has to say where the ranking bites. Also fixed: all five files labelled the link `## Performance Metrics` while the heading is `### Performance Metrics`. Anchors resolved correctly everywhere; only the label's level was wrong. Every clause in the final paragraph re-verified through the PRODUCTION chain (stateCurrentPositionSlice -> stateExtractField), clause by clause, before being written: bold in another section does not win; bold in frontmatter does not win; bold appended last does win; first wins within one form; trailing-space bold yields empty. All four locales carry the same corrected rule. gen-state-md-docs --check reports all 6 targets up to date; heading counts unchanged at 20/20 across all five files, so locale heading-parity is untouched; build:lib, lint and lint:ci all exit 0. Refs #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3812): backfill changeset pr number Refs #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
16 KiB
STATE.md schema reference
STATE.md is GSD Core's living project-memory file — a single Markdown document that records where a project stands, what happened last, and what to run next. This page documents its structure. See docs index.
Overview
Every project managed by GSD Core keeps one STATE.md at .planning/STATE.md. It is read at the start of every workflow and written after every significant action. The file combines:
- YAML frontmatter — machine-readable fields consumed by the status-line hook (
parseStateMd) and thegsd-tools statecommands. - Markdown body — human-readable sections covering current position, accumulated context, session continuity, and performance metrics.
The file is intentionally small (target: under 100 lines). It is a digest of the project's state, not an archive.
YAML frontmatter
Frontmatter appears between --- delimiters at the very start of the file. All fields except gsd_state_version and status are optional; fields may be absent when their data is not yet available.
Annotated example
---
gsd_state_version: '1.0'
milestone: v2.0
milestone_name: Code Quality
status: executing
# Phase-lifecycle fields — all optional (added in v1.40.0, issue #2833)
active_phase: "4.5"
next_action: execute-phase
next_phases: ["4.5"]
progress:
total_phases: 17
completed_phases: 10
total_plans: 84
completed_plans: 47
percent: 59
# Additional fields written by syncStateFrontmatter
current_phase: "4"
current_phase_name: Observability
current_plan: "3"
last_updated: "2026-06-01T12:34:56.789Z"
state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b
last_activity: "2026-06-01"
stopped_at: "Phase 4 P3 execution complete"
paused_at: null
---
Field reference
| Field | Type | When populated | Purpose |
|---|---|---|---|
gsd_state_version |
string ('1.0') |
Always | Schema version; written on first state.* call by syncStateFrontmatter. |
milestone |
string (e.g. v2.0) |
When a milestone is configured | Current milestone version, read from the project's config. |
milestone_name |
string | When a milestone is configured | Human-readable milestone label (e.g. Code Quality). |
status |
string | Always | Current lifecycle stage. Normalised by normalizeStateStatus() — see status values. |
active_phase |
string (e.g. "4.5") |
An orchestrator command is in flight on this phase | The phase number currently being processed. Set to null when between phases. |
next_action |
string | Idle, with a recommended command | The slash command to run next: discuss-phase, plan-phase, execute-phase, or verify-phase. Set to null when an orchestrator is in flight or no recommendation is available. |
next_phases |
YAML flow array (e.g. ["4.5"]) |
Goes with next_action |
The phase ID(s) the next_action applies to (typically 1–2 entries). Set to null under the same conditions as next_action. |
progress.total_phases |
integer | When phase data is available | Total number of phases in the current milestone, derived from ROADMAP.md and the phases directory. |
progress.completed_phases |
integer | When phase data is available | Number of phases that have all plan summaries on disk (i.e. every plan completed). |
progress.total_plans |
integer | When plan files exist | Sum of all plan files across phases in the current milestone. |
progress.completed_plans |
integer | When summary files exist | Sum of completed plan summaries (one SUMMARY.md per executed plan). |
progress.percent |
integer 0–100 | When progress data is available | Milestone progress in the phase dimension (min(completed_plans/total_plans, completed_phases/total_phases)). The status-line progress bar is only rendered when this field is present — its absence suppresses the bar. |
current_phase |
string | When a phase is executing | Phase number extracted from the body Current Phase: field. |
current_phase_name |
string | When a phase has a name | Phase name extracted from the body Current Phase Name: field. |
current_plan |
string | When a plan is in progress | Plan number extracted from the body Current Plan: field. |
last_updated |
ISO-8601 timestamp | Always (on write) | Timestamp of the last syncStateFrontmatter call; written by realClock.nowIso(). |
state_head |
string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, when the resolved repo is not the project's own, or in a planning.sub_repos workspace — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. |
last_activity |
string | When set in body | Date of the last activity, extracted from the body Last Activity: field. |
last_activity_desc |
string | When set in body | Description of the last activity, extracted from the body Last Activity Description: field. |
stopped_at |
string | When a stop point was recorded | Description of the last completed action; scoped to the ## Session body section to avoid matching archive prose. |
paused_at |
string | When the project is paused | Freeform description of the pause point; absent or null when not paused. |
Field cardinality
| Field | Cardinality |
|---|---|
gsd_state_version |
one |
milestone |
optional |
milestone_name |
optional |
current_phase |
optional |
current_phase_name |
optional |
current_plan |
optional |
status |
one |
stopped_at |
optional |
paused_at |
optional |
last_updated |
one |
last_activity |
optional |
last_activity_desc |
optional |
state_head |
optional |
progress.total_phases |
optional |
progress.completed_phases |
optional |
progress.total_plans |
optional |
progress.completed_plans |
optional |
progress.percent |
optional |
Known limitation — multi-repo workspaces. In a workspace configured with
planning.sub_repos, the freshness hint reports unknown rather than a commit age, andstate_headis omitted. The outer workspace can own both.planning/and its own git repo while every code commit lands in a nested child repo, so the outerHEADwould not advance when the code does — measuring against it would report "known fresh" for a STATE.md that is arbitrarily far behind. Reporting unknown is deliberate: a wrong answer here is worse than no answer. Aggregating freshness across several child histories needs a defined semantics and is not part of this feature.
Status values
normalizeStateStatus() in gsd-core/bin/lib/state-document.cjs maps raw body text to these canonical values:
| Canonical value | Matched text (case-insensitive) |
|---|---|
discussing |
contains discussing |
planning |
contains planning or ready to plan |
executing |
contains executing, in progress, or ready to execute |
verifying |
contains verif |
completed |
contains complete or done |
paused |
contains paused or stopped, or paused_at is present |
unknown |
none of the above |
When an orchestrator command is in flight, the convention (issue #2833) is to write the lifecycle stage directly to status:
| Command | status while in flight |
|---|---|
/gsd-discuss-phase |
discussing |
/gsd-plan-phase |
planning |
/gsd-execute-phase |
executing |
/gsd-verify-work |
verifying |
Status lifecycle (ADR-2207)
The Status field follows a strict lifecycle across phase and milestone boundaries:
| Value | Written by | Meaning |
|---|---|---|
Ready to plan |
completePhaseCore (non-last phase) |
Next phase is ready for planning |
All phases complete |
completePhaseCore (last phase) |
All phases done; milestone awaiting formal close |
<version> milestone complete |
milestoneCompleteCore |
Milestone formally closed and archived |
Awaiting next milestone |
milestoneCompleteCore |
Terminal/archived state |
Phase-completion verbs never write Milestone complete (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination).
Status-line rendering scenes
formatGsdState() in hooks/gsd-statusline.js reads the parsed frontmatter and emits the first matching scene. If no new lifecycle fields apply, rendering falls through to the original format byte-for-byte unchanged from v1.38.x.
| Scene | Trigger | Display example |
|---|---|---|
| 1. Phase active | active_phase is populated |
v2.0 [██░░░░░░░░] 20% · Phase 4.5 executing |
| 2. Idle, next recommended | active_phase is null AND both next_action and next_phases are populated |
v2.0 [██░░░░░░░░] 20% · next execute-phase 4.5 |
| 3. Milestone complete | percent is 100 OR completed_phases == total_phases |
v2.0 [██████████] 100% · milestone complete |
| 4. Default fallback | None of the above match | v1.9 Code Quality · executing · ph 1/5 (existing format) |
Scene priority: when both active_phase and next_action are populated, Scene 1 wins — an orchestrator is in flight, so a "next recommendation" would be misleading. This priority is enforced by check order in formatGsdState() and covered by the "scene priority" suite in tests/gsd-statusline.test.cjs.
The progress bar ([██░░░░░░░░] 20%) is appended to the milestone segment only when progress.percent is present in frontmatter; absent means no bar.
Frontmatter parsing constraints
The status-line hook uses regex-based parsing (no full YAML library), so the following constraints apply. They are tested in tests/gsd-statusline.test.cjs.
-
Frontmatter must start at the very first character of the file. Anything — including comments — above the opening
---invalidates the match. The opening---line must be exactly that, with no trailing spaces. -
Comments inside nested blocks are not supported. The
progress:block parser requires the next line to be[ \t]+\w+:. Inserting a# commentbetweenprogress:and its first key breaks the match and the bar disappears. Any documentation belongs in theSTATE.mdbody, not inside frontmatter blocks. -
next_phasesprimary format is single-line flow. The parser first triesnext_phases: ["4.5", "4.6"]. Block sequences (- 4.5\n- 4.6) are also parsed but are less reliable for status-line rendering. Prefer single-line flow fornext_phasesto keep the regex-based parser predictable. If many candidate phases need recording for documentation purposes, store them in theSTATE.mdbody.
If a future change replaces the regex parser with a full YAML library, these constraints can be relaxed and the tests updated accordingly.
Markdown body sections
The body (everything after the closing ---) follows the template in gsd-core/templates/state.md. The standard sections are:
Project Reference
Points to .planning/PROJECT.md. Contains:
- Core value — the one-liner from
PROJECT.md's Core Value section. - Current focus — which phase is active.
Current Position
Where the project stands right now:
| Field | Format |
|---|---|
Phase: |
X of Y (Phase name) |
Plan: |
A of B in current phase |
Status: |
Free text, e.g. Ready to execute, Executing Phase 4, Phase complete — ready for verification |
Last activity: |
ISO date (YYYY-MM-DD) when handler-written; narrative prose when executor-authored |
Progress: |
Visual bar, e.g. [████░░░░░░] 40% |
Every field in this section is single-valued, and the section is overwritten rather than appended to. A second Phase: line is not a second position and is not history — it is malformed input. Readers do not resolve a duplicate by document order; they resolve it by form, checked in this order regardless of where each form appears within the ## Current Position section — bold **Phase:** value anywhere within the section, then plain Phase: value at the start of a line, then a pipe-table | Phase | value | row — and only within the winning form does the first occurrence win. A bold or plain line outside this section — an earlier ## Archive entry, or a bold line in the YAML frontmatter — is never consulted; production always scopes to the ## Current Position section first (#2956) before applying the form ranking. Two practical consequences: a duplicate written in a higher-ranked form wins even if it comes last in the section, so a bold line appended "for emphasis" silently overrides an earlier plain line instead of being ignored; and an indented Phase: line is invisible to the plain form (which anchors at line-start) and falls through to whatever form matches next. Two sharp edges follow from "higher form wins": a bold line whose value is only trailing whitespace resolves to an empty string rather than falling through to a valid plain line below it, and a pipe-table row whose label cell is itself bolded (| **Phase:** | value |) is caught by the bold pattern first, returning the literal cell text including its pipes. Write the section by replacing it, never by adding a line.
Progress history does not belong here. It accumulates in ### Performance Metrics below, which is the section designed to grow.
The Status: and Last activity: fields in this section are updated by GSD handlers when the existing value is a known template default (Knuth invariant: executor-authored values are preserved). The full list of known handler defaults is in KNOWN_TEMPLATE_DEFAULTS inside gsd-core/bin/lib/state-document.cjs.
Performance Metrics
Execution velocity tracking:
- Total plans completed, average duration per plan.
- Per-phase breakdown table (
Phase | Plans | Total | Avg/Plan). - Recent trend: Improving / Stable / Degrading.
Updated after each plan completion.
Accumulated Context
Decisions — a summary of recent decisions affecting current work (full log lives in PROJECT.md). Added via gsd-tools state add-decision.
Pending Todos — count and reference to .planning/todos/pending/. Captured via /gsd-capture.
Blockers/Concerns — issues affecting future work, prefixed with the originating phase. Added via gsd-tools state add-blocker; resolved via gsd-tools state resolve-blocker.
Session Continuity
Enables instant session resumption:
Last session:— ISO-8601 timestamp of the last session.Stopped at:— description of the last completed action.Resume file:— path to a.continue-here*.mdfile if one exists, otherwiseNone.
Backward compatibility
The phase-lifecycle fields (active_phase, next_action, next_phases, and progress.percent for the bar) are additive and opt-in per project:
- A
STATE.mdwith none of the lifecycle fields populated renders byte-for-byte identically to v1.38.x and earlier. - Adding any lifecycle field is opt-in — the renderer degrades gracefully when fields are absent.
- The progress bar is opt-in even when the
progressblock exists: onlyprogress.percenttriggers the bar;total_phasesandcompleted_phasesalone do not.
The formatGsdState #2833 backward compatibility test suite in tests/gsd-statusline.test.cjs locks this guarantee; any change that breaks legacy STATE.md rendering will fail the suite.