feat(#3227): publish machine-readable state contract at step boundaries (#3824)

* feat(#3227): publish machine-readable state contract at step boundaries

Adds src/state-contract.cts, a best-effort publisher that writes
.planning/state.json (contract 1.0.0) at 11 step-boundary commands, so
external tools read a versioned contract instead of parsing STATE.md and
ROADMAP.md heuristically.

Composes existing owners rather than re-deriving: phase rows come from a
new locateProgressTable extracted from deriveProgressFromRoadmap (so the
snapshot can never disagree with GSD's own progress counters), milestone
identity from getMilestoneInfo, and next from classifyProject. Owners are
required lazily to avoid the state -> state-contract -> smart-entry ->
state require cycle.

Also fixes a pre-existing defect in scripts/lint-test-file-count.cjs
(maintainer-approved as a second concern): testEffectivePrefix never
stripped the suite qualifier, so 65 dotted test files counted against no
module and 9 mis-bucketed into a shorter one. Allowlist re-baselined for
the 74 files the gate can now see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3227): backfill PR number into the changeset fragment

pr:0 -> pr:3824 now that the PR exists. Doc-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3227): shape hostile-name fixtures away from the scan corpus

The two hostile-input fixtures used a literal phrase from
scripts/prompt-injection-scan.sh's corpus, so CI's Security Scan redded on
this file. These tests assert that an arbitrary phase name round-trips into
state.json as inert data -- the property holds for any string, so the
injection flavor is illustrative, not load-bearing.

Reshaped to a hyphenated fake instruction tag, which stays hostile-looking
while matching none of the scanner's patterns. Allowlisting the file was
rejected: that mechanism is for suites whose subject IS injection defense,
and it would blind the scanner to this whole file permanently.
See DEFECT.PROMPT-INJECTION-SCAN-COLLISION.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3227): ratchet the state-contract mutation floor to its measured score

The module was registered at minScore 50, the ratchet's minimum permitted
floor for a newly-registered module whose score had not been measured. This
PR's own Stryker shard measured 66.25% (run 32769289750, job 97565813640),
so the floor moves to floor(measured) - 1 = 65, per the rule the registry
documents.

66.25 is below TARGET_MUTATION_SCORE (80), so this stays a ratchet
candidate: raise as the tests improve, never lower.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-24 17:56:02 -04:00
committed by GitHub
parent fb9823e1e1
commit 596540f864
23 changed files with 2739 additions and 25 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3824
---
**GSD now publishes a machine-readable state snapshot at every step boundary** — external tools that show project state no longer have to parse STATE.md and ROADMAP.md heuristically. `.planning/state.json` carries a versioned `contract`, the current `milestone`, every phase with its `complete`/`in_progress`/`pending` status, and the same recommended `next` action the `/gsd` front door routes. The write is best-effort and can never fail, slow, or alter the command that triggered it. (#3227)

1
.gitignore vendored
View File

@@ -209,6 +209,7 @@ build/
/gsd-core/bin/lib/planning-inspect.cjs
/gsd-core/bin/lib/planning-command-router.cjs
/gsd-core/bin/lib/plan-document.cjs
/gsd-core/bin/lib/state-contract.cjs
/gsd-core/bin/lib/pattern.cjs
/gsd-core/bin/lib/text-lines.cjs
/gsd-core/bin/lib/token-scanner.cjs

View File

@@ -124,6 +124,9 @@ Leaf module owning the parse of a `*-PLAN.md` document BODY: `<objective>` extra
### Planning Inspect Module
Module owning the **schema-v1 canonical planning snapshot** emitted by the read-only `planning inspect` query (#2790), for downstream harness UIs that need truthful `.planning/` state without parsing ROADMAP/REQUIREMENTS/PLAN/SUMMARY Markdown a second time. `buildPlanningInspect(cwd) → payload`; `cmdPlanningInspect(cwd, raw)` emits it through `output()` (so the existing >50 KB `@file:` spill seam applies unchanged). `PLANNING_INSPECT_SCHEMA_VERSION = 1` is the wire contract — a consumer MUST reject any other value rather than best-effort-parse an unknown shape. Composes, never re-derives: milestone identity/windowing and phase enumeration via `buildPlanningSnapshot` (Planning Snapshot Module), completion via `isPhaseComplete` (§7.4, disk-strict), live-plan counting via `scanPhasePlans` (§7.5), percent via `clampPercent` (§7.6), STATE fields via `stateFieldValue`/`stateCurrentPositionSlice` (§7.7), plan bodies via `parsePlanDocument`, requirement IDs via `parseRequirements`, UAT items via `parseUatItems`/`selectPhaseUatFiles`. **It deliberately does NOT serialize `PlanningSnapshot`**: that shape is the §8.1 diagnostic-rule subject and is explicitly additive/growing (4 fields at Phase 10, 20+ by Phase 12), so handing it to external consumers would freeze an internal contract by accident (Hyrum's Law) — this module declares its own flat schema and maps into it, and a field added to `PlanningSnapshot` must never change schema-v1 output. Three frozen enums carry every non-answer — `INSPECT_DIAGNOSTIC`, `TASK_STATUS` (`done|pending|unknown`), `PROVENANCE` (`task_scoped|plan_scoped|absent`) and `AGREEMENT` (`agreed|conflicting|unknown`) — because unknown or conflicting evidence serializes as `unknown` plus a diagnostic and is **never inferred, reconciled, or defaulted**; keys are always present, `null` is the explicit non-answer. Roadmap acceptance, verification and UAT are reported **side by side and never folded into one verdict**, and a ROADMAP checkbox is emitted with `authoritative: false` per §7.4. **Not a diagnostic rule**, and deliberately NOT registered in `scripts/lint-planning-snapshot-bypass-drift.cjs`, which is `DIAGNOSTIC_RULE_FUNCTIONS`-scoped and must remain prunable to zero when #3309 lands. Dispatched by the Planning Command Router (`src/planning-command-router.cts`, family `planning`, subcommand `inspect`, no arguments in v1 — a stray positional or unknown flag is a fail-loud `ERROR_REASON.USAGE`). Source of truth: `gsd-core/bin/lib/planning-inspect.cjs` (generated from `src/planning-inspect.cts`). Design: `.gsd/phase/feat-2790-planning-inspect/40-design.md`.
### State Contract Module
Module owning the **machine-readable state contract v1** published to `.planning/state.json` at every step boundary (#3227), so an external reader (GSD Workbench, an editor extension, a dashboard) binds to a versioned contract instead of parsing STATE.md/ROADMAP.md heuristically. `buildStateContract(cwd, deps?) → snapshot` is PURE (writes nothing); `publishStateContract(cwd, deps?) → {published, reason}` writes it and **never throws for any input**. Wire shape, keys always present and in a pinned order: `contract` (semver `1.0.0`; additive-only under 1.x — the consumer's version gate), `flavor` (`core`), `milestone` (`"v1.1 — Hardening"`, or the bare version when the roadmap carries no name, or `null`), `phases[]` (`{number, name, status}`; `number` is a STRING because `01` and `2.1` are both real ids), `next` (`{command, label, reason}` or `null`), `updated_at`. Two frozen enums are the wire vocabulary: `PHASE_STATUS` (`complete|in_progress|pending`) and `PUBLISH_REASON` (`published|no_planning_dir|write_failed`). **Composes, never re-derives**: phase rows from `locateProgressTable` (Phase Lifecycle Module — the SAME `## Progress` locator `deriveProgressFromRoadmap` uses, extracted in this change precisely so `state.json` cannot disagree with GSD's own progress counters), milestone identity from `getMilestoneInfo` (Roadmap Parser Module), the recommended action from `classifyProject` (Smart Entry Module) so `next` equals smart-entry's routing BY CONSTRUCTION rather than by a second copy of its table, paths from `planningPaths` (workstream-aware), and I/O through `platformReadSync`/`platformWriteSync` — the latter is **already atomic** (sibling tmp + `retryRenameSync`), so this module adds no sixth atomic-write helper and takes **no `withPlanningLock`** (that lock is non-re-entrant and several phase commands already hold it). **Every owner is required LAZILY inside the function body**, never at module top level: `state.cts` imports this module and `smart-entry.cts` destructures off `state.cjs` at load time, so a static import would close the cycle `state → state-contract → smart-entry → state` and bind `undefined`. **Best-effort by contract**: a missing ROADMAP.md, an unreadable document, or an unwritable target degrades to a typed `reason` and can never change the parent command's exit code or stdout; a directory with no `.planning/` is left untouched rather than having one created for it. Publishing is wired at the 11 boundary commands (`state begin-phase`/`planned-phase`/`advance-plan`/`complete-phase`/`milestone-switch`, `phase add`/`add-batch`/`insert`/`remove`/`complete`, `milestone complete`) and deliberately NOT at their early-return error or idempotent-no-op paths, so a refreshed `updated_at` always means something actually moved. Known limits: `phases: []` cannot be told apart from "no ROADMAP" (the 1.0 schema has no diagnostic channel — `planning inspect` is the surface that does), a `Deferred` roadmap phase folds to `pending` (four roadmap statuses, three wire values), and `phases[]` is not milestone-scoped. Contrast with the **Planning Inspect Module**: that is a rich, diagnostic-carrying PULL query a consumer runs; this is a small PUSH artifact a consumer watches. Source of truth: `gsd-core/bin/lib/state-contract.cjs` (generated from `src/state-contract.cts`). Design: `.gsd/phase/feat-3227-state-contract/40-design.md`. Test anchor: `tests/state-contract.test.cjs`.
### Health Diagnostic Types Module
Leaf module owning the `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types shared between the Health Diagnostic Module (the evaluator) and the Health Diagnostic Rule Groups (the eight rule-group files it concatenates). Split out of `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) to break a CJS circular dependency: the evaluator must `require()` every rule-group file to populate `RULES`, and every rule-group file needs these enums/types — if the rule-group files required the evaluator back, the require cycle would resolve `module.exports` before it is assigned. This leaf has no runtime dependency on either side of that cycle. Source of truth: `gsd-core/bin/lib/health-diagnostic-types.cjs` (generated from `src/health-diagnostic-types.cts`).

View File

@@ -3551,3 +3551,26 @@ See [Resolve verify-command path findings](how-to/resolve-verify-command-path-fi
**Known limits:** convergence cycles stay sequential by design (`review → replan → re-review` has a genuine data dependency), so this speeds up each pass rather than reducing the number of passes; there is no concurrency bound, so every selected lane dispatches at once; and reviewer instances sharing one adapter dispatch concurrently against that single provider, which is the most likely way to hit a limit.
**Reference:** [Configuration](CONFIGURATION.md#parallel-reviewer-lanes-for-gsd-review-3034) · [Enable parallel reviewer lanes](how-to/enable-parallel-reviewer-lanes.md) · [Commands](COMMANDS.md)
### 166. Machine-Readable State Contract (`.planning/state.json`)
**Purpose:** External tools that display GSD project state — a workbench, a dashboard, an editor extension — had to parse `STATE.md` and `ROADMAP.md` heuristically. Those are human surfaces: their shape drifts as the templates evolve, and every consumer ends up carrying a brittle second parser that silently reports wrong numbers after an upgrade. GSD now publishes a small, versioned JSON snapshot instead, so the reader binds to a contract rather than to markdown (#3227).
**Behavior:** At every step boundary, GSD writes `.planning/state.json` — `contract`, `flavor`, `milestone`, `phases[]`, `next`, `updated_at`. The boundaries are `state begin-phase` / `planned-phase` / `advance-plan` / `complete-phase` / `milestone-switch`, `phase add` / `add-batch` / `insert` / `remove` / `complete`, and `milestone complete`. The write is best-effort and completely invisible to the command that triggered it: it cannot change an exit code, cannot change stdout, and cannot fail a workflow. Readers prefer the file when it is present and fall back to markdown when it is not.
**Requirements:**
- REQ-SC-01: `contract` is semver, `1.0.0` at introduction. Consumers gate on the MAJOR version; `1.x` changes are additive only. Every key is ALWAYS present — an unknown value is `null`, never an omitted key, because an omitted key is itself an observable a consumer would bind to.
- REQ-SC-02: `phases[]` carries `{number, name, status}` per phase, `status` drawn from exactly `complete | in_progress | pending`. `number` is a string (`"01"` and `"2.1"` are both real ids and neither survives a number cast); `name` is `null` when the roadmap gives a phase no name, never a fabricated placeholder.
- REQ-SC-03: `next` is the same recommended action the `/gsd` front door routes, derived from the smart-entry classifier itself rather than from a second copy of its routing table.
- REQ-SC-04: A missing `ROADMAP.md`, a missing or unreadable `.planning/`, an unwritable target, or any other failure NEVER errors the parent command. A directory that is not a GSD project stays untouched — the publisher will not create `.planning/` in order to publish into it.
- REQ-SC-05: The skills own the file; readers never write it. It is a derived cache — safe to delete, regenerated at the next boundary.
**Composed, never re-derived.** Milestone identity comes from `getMilestoneInfo`; phase rows from `locateProgressTable`, the same `## Progress` locator the progress counters use, so `state.json` can never disagree with the rest of GSD about which phases are complete; the recommended action from `classifyProject`. This module introduces no second answer to any question GSD already answers.
**Why it does not reuse `planning inspect`'s schema.** The two surfaces answer different questions and have opposite shapes. `planning inspect` is a rich, diagnostic-carrying **pull** query a consumer runs; this is a small **push** artifact a consumer watches. Publishing `planning inspect`'s payload at every `phase add` would mean opening every plan, summary and requirements document on a hot path, and freezing a much larger surface as a contract.
**It costs up to three bounded git calls per boundary.** Deriving `next` from the smart-entry classifier means inheriting its git signals — `git status --porcelain`, and `git log @{u}..HEAD`. Each is timeout-bounded and swallows every error, so nothing can hang or fail because of it, but a command like `phase add` did not previously touch git at all. "Invisible to the parent command" is exact about exit code and output; it is not a claim about latency.
**Known limits:** an empty `phases: []` cannot be told apart from "no `ROADMAP.md`" or "roadmap unreadable" — the `1.0` schema carries no diagnostic channel, and `planning inspect` is the surface that does. A roadmap phase marked `Deferred` is reported as `pending`, because the roadmap vocabulary has four values and this contract has three; inventing a fourth wire value would break every existing reader. `phases[]` is not milestone-scoped, so a long-running project lists every phase it has ever had.
**Reference:** [Consume the state contract](how-to/consume-the-state-contract.md) · [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md)

View File

@@ -501,6 +501,7 @@
"spec-section.cjs",
"stale-bake-guard.cjs",
"state-command-router.cjs",
"state-contract.cjs",
"state-document.cjs",
"state-io.cjs",
"state-transition.cjs",

View File

@@ -631,6 +631,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `spec-section.cjs` | SPEC section-status helper (compiled from `src/spec-section.cts`, gitignored) — the single source of truth for the canonical SPEC headings (suffix-tolerant) and markdown-table row counting; `specSectionStatus`/`countSectionDataRows` decide per-section "supplied" for plan-phase's spec-less probe fallback, replacing ad-hoc awk (contract pinned by `tests/spec-section.test.cjs`) |
| `stale-bake-guard.cjs` | Warns when configuration changes after a static-frontmatter runtime bake (#1688) |
| `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` |
| `state-contract.cjs` | Machine-readable state contract v1 published to `.planning/state.json` at every step boundary (compiled from `src/state-contract.cts`, gitignored; #3227) — `buildStateContract(cwd, deps?)` is pure, `publishStateContract(cwd, deps?)` writes and never throws. `contract: "1.0.0"` is the wire version consumers gate on; frozen `PHASE_STATUS` (`complete`/`in_progress`/`pending`) and `PUBLISH_REASON` (`published`/`no_planning_dir`/`write_failed`) are the closed vocabularies. Composed from `locateProgressTable`, `getMilestoneInfo` and `classifyProject` so it can never disagree with GSD's own progress counters or front-door routing; owners are required lazily to avoid the `state → state-contract → smart-entry → state` require cycle. Best-effort by contract: it cannot change the exit code, stdout, or success of the boundary command that triggered it |
| `health-diagnostic-rules/state-consistency.cjs` | Health-diagnostic rules: STATE.md consistency checks (W002, W011, W021, W026) against config/ROADMAP/disk, ported behavior-preserving from `cmdValidateHealth`; W024 (state_head freshness) is a documented gap, deliberately not migrated (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `state-io.cjs` | Abstracts state IO modes — filesystem, sandboxed storage, or session log (#1680) |
| `state-transition.cjs` | Implements the STATE.md field-classification table and the pure `transitionCore` mutation path (#1769) |

View File

@@ -31,6 +31,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Resolve an ESLint glob-coverage finding](how-to/resolve-eslint-coverage-findings.md) — bring a source file that matches no lint rule under coverage, or record a reasoned exemption
- [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) — turn on `state ~N commits back`, and tell "STATE.md is fresh" apart from "freshness could not be established"
- [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md) — read `planning inspect` from a dashboard or harness, and tell "nothing to report" apart from "could not look"
- [Consume the state contract](how-to/consume-the-state-contract.md) — read `.planning/state.json` from a workbench or editor extension, gate on the contract version, and tell "nothing to show" apart from "could not look"
- [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) — make `.planning/` local-only, including untracking files git already tracks (the step `.gitignore` alone cannot do)
- [Publish PRs without planning artifacts](how-to/publish-prs-without-planning-artifacts.md) — keep `.planning/` committed locally, so worktrees and `/gsd-undo` keep working, while `planning.pr_strict` keeps every planning path out of the branch you push
- [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality

View File

@@ -0,0 +1,199 @@
# Consume the state contract
You are building something that shows where a GSD project stands — a workbench,
a dashboard, a status bar, an editor extension. `.planning/state.json` gives you
that as one small JSON file that GSD refreshes on its own, so you never have to
parse `STATE.md` or `ROADMAP.md` heuristically.
This guide covers the whole path from *nothing* to *reading a value you can
trust*, including the two things integrations get wrong: **checking the contract
version before anything else**, and **telling "there is nothing to show" apart
from "I could not look."**
## Before you start
Nothing. There is no command to run, no config key to set, and no flag to pass.
GSD writes the file itself at every step boundary — beginning or completing a
phase, advancing a plan, adding, inserting, removing or completing a phase, and
switching or completing a milestone.
Two consequences worth internalizing before you write any code:
- **The file may legitimately not exist yet.** A project that has not reached a
step boundary since it was created has never published one. That is normal, not
an error.
- **You are a reader, never a writer.** GSD owns this file and overwrites it
wholesale. Anything you write into it is lost at the next boundary.
## 1. Find the file
```
<project>/.planning/state.json
```
If the project uses [workstreams](work-in-parallel-with-workstreams.md), each
workstream has its own planning root and therefore its own snapshot:
```
<project>/.planning/workstreams/<name>/state.json
```
## 2. Check the contract version first
```json
{ "contract": "1.0.0" }
```
**Do this before you touch any other field.** `contract` is semver. Under `1.x`
changes are additive only — new keys may appear, existing keys keep their meaning
— so gate on the **major** version and tolerate unknown minors:
```javascript
const snapshot = JSON.parse(await fs.readFile(statePath, 'utf8'));
const [major] = snapshot.contract.split('.');
if (major !== '1') {
throw new Error(`Unsupported state contract: ${snapshot.contract}`);
}
```
Best-effort-parsing a shape you were not written against is how an integration
starts silently reporting wrong numbers after an upgrade. A hard failure is the
kinder outcome.
`flavor` is `"core"` and tells you which GSD edition produced the file.
## 3. Read the values
Every key is **always present**. A value that is not known is `null` — it is
never omitted, so `"milestone" in snapshot` is not a meaningful test.
| Key | Type | Meaning |
|---|---|---|
| `contract` | string | Semver of this schema. Check it first. |
| `flavor` | string | The GSD edition. `"core"`. |
| `milestone` | string \| null | Display string for the current milestone, e.g. `"v1.1 — Hardening"`, or just `"v1.1"` when the roadmap carries no name for it. `null` when no milestone is established. |
| `phases` | array | Every phase the roadmap knows, in roadmap order. |
| `next` | object \| null | The recommended next action — the same one `/gsd-next` would offer. |
| `updated_at` | string | ISO-8601 timestamp of this publish. |
Each entry in `phases` has exactly three keys:
| Key | Type | Meaning |
|---|---|---|
| `number` | string | The phase id as the roadmap spells it — `"1"`, `"01"`, `"2.1"`. A **string**, because `"01"` and `"2.1"` are both real and neither survives a number cast. |
| `name` | string \| null | The phase name. `null` when the roadmap gives the phase a number but no name — never a fabricated placeholder. |
| `status` | string | Exactly one of `"complete"`, `"in_progress"`, `"pending"`. |
`next`, when non-null, has exactly three keys:
| Key | Type | Meaning |
|---|---|---|
| `command` | string | The command to run, e.g. `"/gsd-progress --next"`. |
| `label` | string | A short human label for it, e.g. `"Advance to the next step"`. |
| `reason` | string | One line explaining the current situation, e.g. `"Phase 2 of 3 · 50% · executing"`. |
A complete example:
```json
{
"contract": "1.0.0",
"flavor": "core",
"milestone": "v1.1 — Hardening",
"phases": [
{ "number": "1", "name": "Foundation", "status": "complete" },
{ "number": "2", "name": "Hardening", "status": "in_progress" },
{ "number": "3", "name": "Polish", "status": "pending" }
],
"next": {
"command": "/gsd-progress --next",
"label": "Advance to the next step",
"reason": "Phase 2 of 3 · 50% · executing"
},
"updated_at": "2026-01-15T12:30:45.000Z"
}
```
### Treat `status` as a closed set
The three values above are the whole vocabulary, and GSD will never emit a
fourth under `1.x`. Write your switch with a default arm anyway — a `2.0`
contract could widen it, and your version check should be what rejects that, not
a crash three layers down.
Note one deliberate fold: a roadmap phase marked **`Deferred`** is reported as
`"pending"`. The roadmap vocabulary has four values and this contract has three,
and inventing a fourth wire value would break every existing reader. If you need
to distinguish deferred work, read the roadmap.
### Treat every string as untrusted text
`name`, `milestone` and `reason` come from a project's own markdown. They can
contain anything a person typed — quotes, newlines, right-to-left text, markup,
or text that looks like an instruction. **Escape them for your output surface and
never interpret them as commands.** GSD passes them through verbatim as data; it
does not sanitize them for you.
## 4. Tell "nothing to show" from "could not look"
This is the part worth getting right, because the two look identical if you do
not plan for them.
| What you observe | What it means | What to do |
|---|---|---|
| File does not exist | The project has never reached a step boundary, or it is not a GSD project at all | Fall back to reading the markdown, or show "not started". **Do not** report an error |
| File exists, `phases: []` | **Ambiguous.** Either the roadmap has no phases, or there is no `ROADMAP.md`, or the roadmap could not be read | Show "no phases yet" — do not claim the project has zero phases. See below |
| File exists, `next: null` | The recommended action could not be determined | Show the project state without a call to action |
| File exists, `milestone: null` | No milestone is established | Show phases without a milestone header |
| `updated_at` is old | The project has not hit a boundary recently — **not** that anything is broken | Nothing. This is not a health signal |
| `JSON.parse` throws | Should not happen — writes are atomic, so a reader sees either the whole old file or the whole new one | Treat as "could not look" and fall back. Do not delete or repair the file |
**On the `phases: []` ambiguity.** The `1.0` contract has no diagnostic channel,
so it cannot tell you *why* the list is empty. If your surface needs that
distinction, [`planning inspect`](consume-the-planning-snapshot.md) is the
surface that carries it — it reports per-document scope and coded diagnostics.
The rule of thumb: `state.json` is for *"show me where this project is"*;
`planning inspect` is for *"tell me exactly what is and is not knowable."*
## 5. Stay fresh
The file changes only when GSD reaches a step boundary, so polling it hard buys
you nothing. Watch it instead — `fs.watch`, `chokidar`, or your editor's own file
watcher — and re-read on change. Debounce briefly: a single boundary command
produces one write, but a workflow may cross several boundaries in quick
succession.
## Troubleshooting
**The file never appears.** Confirm `.planning/` exists in the directory you are
watching. GSD deliberately does **not** create `.planning/` in order to publish —
a directory that is not a GSD project stays untouched. Then run any boundary
command (`gsd-tools phase complete 1`, say) and check again.
**The file appears somewhere I did not expect.** You are probably in a workstream
project; see the path in step 1. `GSD_WORKSTREAM` selects which planning root is
current.
**A phase I can see in `ROADMAP.md` is missing from `phases`.** Phases numbered
`0.x` and `999.x` are sentinels — backlog and icebox — and are excluded by
design, consistently with every other GSD surface. A row whose `Phase` cell does
not begin with a digit is also skipped.
**`phases` disagrees with what the roadmap shows.** It should not: phase status
is read from the roadmap's `## Progress` table, the same source GSD's own
progress counters use. If the roadmap has no `## Progress` table, the `## Phases`
checkbox list is used instead, and a checkbox can only say complete or not — the
in-progress phase is identified from `STATE.md`. Regenerating the progress table
resolves most disagreements.
**Should I commit `state.json`?** Your call. GSD does not add it to
`.gitignore`. It is a derived cache — safe to delete, regenerated at the next
boundary — so committing it mostly creates merge noise. If you keep `.planning/`
out of your repo entirely, see
[Keep planning docs out of a shared repo](keep-planning-docs-private.md).
## Related
- [Consume the planning snapshot](consume-the-planning-snapshot.md) — the richer,
diagnostic-carrying, pull-based surface
- [Features](../FEATURES.md) — why this contract is shaped the way it is
- [Work in parallel with workstreams](work-in-parallel-with-workstreams.md)

View File

@@ -138,6 +138,8 @@ export default tseslint.config(
'gsd-core/bin/lib/secrets.cjs',
'gsd-core/bin/lib/smart-entry.cjs',
'gsd-core/bin/lib/phase-lifecycle.cjs',
// #3227: tsc-generated artifact — lint src/state-contract.cts, not this.
'gsd-core/bin/lib/state-contract.cjs',
'gsd-core/bin/lib/workstream-name-policy.cjs',
'gsd-core/bin/lib/decisions.cjs',
'gsd-core/bin/lib/validate.cjs',

View File

@@ -1,17 +1,44 @@
{
"_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to \u2264 2, remove its entry entirely. New entries require justification in PR description.",
"modules": {
"adr-parser": {
"files": [
"adr-parser.property.test.cjs",
"adr-parser.test.cjs",
"adr-parser.unit.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"config": {
"files": [
"config-defaults-runtime-exclusion.test.cjs",
"config-field-docs.test.cjs",
"config-get-default.test.cjs",
"config-schema.property.test.cjs",
"config-validation.test.cjs",
"config.test.cjs"
],
"issue": "TBD"
},
"context-predicates": {
"files": [
"context-predicates-query.test.cjs",
"context-predicates.property.test.cjs",
"context-predicates.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"frontmatter": {
"files": [
"frontmatter-cli.test.cjs",
"frontmatter.property.test.cjs",
"frontmatter.test.cjs",
"frontmatter.unit.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"graphify": {
"files": [
"graphify-auto-update.slow.test.cjs",
@@ -151,9 +178,65 @@
"files": [
"prompt-budget-cli.test.cjs",
"prompt-budget-parity.test.cjs",
"prompt-budget.test.cjs"
"prompt-budget.property.test.cjs",
"prompt-budget.test.cjs",
"prompt-budget.unit.test.cjs"
],
"issue": "2929"
},
"mcp-catalog": {
"files": [
"mcp-catalog-parity.install.test.cjs",
"mcp-catalog.property.test.cjs",
"mcp-catalog.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"model-catalog": {
"files": [
"model-catalog-runtime-defaults.test.cjs",
"model-catalog-valid-tiers.test.cjs",
"model-catalog.unit.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"package-legitimacy": {
"files": [
"package-legitimacy-gate.test.cjs",
"package-legitimacy.property.test.cjs",
"package-legitimacy.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"runtime-homes": {
"files": [
"runtime-homes-descriptor-drive.test.cjs",
"runtime-homes-legacy-ids-drift-guard.test.cjs",
"runtime-homes.property.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"section-manifest": {
"files": [
"section-manifest-init-facts.test.cjs",
"section-manifest.property.test.cjs",
"section-manifest.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
},
"workflow-fragments": {
"files": [
"workflow-fragments-emission.install.test.cjs",
"workflow-fragments.property.test.cjs",
"workflow-fragments.test.cjs"
],
"issue": "3227",
"justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl."
}
}
}

View File

@@ -74,7 +74,16 @@ function prodPrefix(filename) {
function testEffectivePrefix(testName) {
const bare = testName
.replace(/\.integration\.test\.(ts|cjs)$/, '')
.replace(/\.test\.(ts|cjs)$/, '');
.replace(/\.test\.(ts|cjs)$/, '')
// #3227: strip a trailing suite/kind qualifier (`.unit`, `.property`,
// `.security`, `.slow`, `.install`, `.rule`, `.regression`, `.qa`, ...).
// A production prefix comes from `prodPrefix`, which strips only the file
// extension, so it never contains a dot — any surviving trailing `.<word>`
// is a qualifier. Without this, `frontmatter.property` matched no module at
// all (65 files counted against nothing repo-wide) and `state-contract.unit`
// matched the SHORTER `state` module through the `prefix + '-'` rule
// (9 files mis-bucketed).
.replace(/\.[a-z0-9]+$/i, '');
const m = bare.match(/^(?:feat|bug|enh|fix)-\d+(?:-\d+)*-(.+)$/);
return m ? m[1] : bare;
}

View File

@@ -296,6 +296,39 @@ const COVERED = {
tests: ['tests/model-catalog.unit.test.cjs'],
minScore: 58,
},
// state-contract: net-new module from #3227. Without this entry the
// Stryker gate reports has_work: "false" and SKIPS it entirely — the
// exact gap #2790 (planning-inspect / plan-document / planning-command-router)
// and #3007 (model-catalog) each had to fix after the fact.
//
// Same #2790 precedent as planning-inspect / model-catalog above: this
// shard points at tests/state-contract.unit.test.cjs, NOT
// tests/state-contract.test.cjs — the latter spawns a `gsd-tools` child
// process per case via runGsdTools, and Stryker's command runner treats
// the whole `node --test <file>` invocation as ONE test costing whatever
// its slowest case costs, re-run once per mutant, so it cannot finish
// inside the 15-minute shard cap. tests/state-contract.unit.test.cjs is
// spawn-free and in-process.
//
// Measured CI score (GitHub Actions run 32769289750, job 97565813640,
// `Stryker (state-contract)`, PASSED in 2m23s):
// state-contract 66.25% → floor 65 (below TARGET_MUTATION_SCORE (80) —
// ratchet candidate like planning-inspect (56) and model-catalog (58):
// comfortably clears its own floor but has real room to grow. Raise as
// its tests improve, never lower it.)
// Floor follows this file's documented rule, minScore = floor(measured) - 1,
// matching the sibling precedent exactly (57.03 → 56, 76.58 → 75,
// 95.65 → 94, 59.62 → 58, 66.25 → 65).
//
// The floor MUST come from a CI shard, never a local run: local runs count
// timeouts as kills and inflate scores badly (this file already records
// prompt-budget 99.6% local vs 68.33% CI, and config-schema 69.7% local vs
// 54.55% CI).
'state-contract': {
cjs: 'gsd-core/bin/lib/state-contract.cjs',
tests: ['tests/state-contract.unit.test.cjs'],
minScore: 65,
},
};
// ── Files that, when changed, invalidate ALL modules ─────────────────────────

View File

@@ -27,6 +27,7 @@ export const CANONICAL_EXACT: ReadonlySet<string> = new Set([
'WINDOWS.md', // #3224: broken-windows ledger (src/broken-windows.cts, LEDGER_FILE_NAME)
'STATE-ARCHIVE.md', // state.cts's cmdStatePrune writes this at the .planning/ root
'milestone.lock', // #3311: milestone (phase + session) claim (src/milestone-lock.cts); persistent, unlike the transient STATE.md.lock/WAITING.json
'state.json', // #3227: machine-readable state contract published at step boundaries (src/state-contract.cts)
]);
// Pattern-match canonical file names (regex tests on the basename)

View File

@@ -29,6 +29,9 @@ const { resolveQuickTaskSummaryFile } = auditMod;
import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateContract = require('./state-contract.cjs');
const { publishStateContract } = stateContract;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, isSentinelPhaseDir } = phaseIdMod;
import { escapeRegex } from './pattern.cjs';
@@ -1154,6 +1157,19 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
};
output(result, raw);
// #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
// state.json `updated_at` must always mean something on disk actually
// moved. This site is unconditional because every reachable path either
// exits via `error()` (process.exit — refusals like a truncated milestone
// window, an unstarted phase, or an invalid version never reach here) or
// returns early on `--dry-run` (before any mutation, see the `dry_run:
// true` branch above) — the only way execution reaches this line is after
// the unconditional MILESTONES.md `platformWriteSync` a few lines above,
// which always runs (new file, empty file, or append) once the run is
// committed to mutating. Best-effort — cannot throw, cannot change this
// command's exit code or output. publishStateContract resolves the
// workstream planning root itself via planningPaths.
publishStateContract(cwd);
}
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {

View File

@@ -21,6 +21,7 @@
*/
import { findTableWithColumns } from './markdown-table.cjs';
import type { MarkdownTable } from './markdown-table.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
import phaseIdMod = require('./phase-id.cjs');
const { isSentinelPhaseId } = phaseIdMod;
@@ -33,8 +34,10 @@ export interface RoadmapProgress {
}
/**
* Derive completed_phases, total_phases, and total_plans from ROADMAP content.
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
* #3227: the single owner of "where is this ROADMAP's Progress table".
* Lifted verbatim out of deriveProgressFromRoadmap so `state-contract.cts`
* enumerates phases from THE SAME table this module derives its counts from.
* A second copy of this locator is the DEFECT.GENERATIVE-FIX shape.
*
* ADR-2143 §3 ("addressed by NAME, never ordinal"): the Progress table is
* located via the markdown-table seam's `findTableWithColumns`, which is
@@ -53,6 +56,25 @@ export interface RoadmapProgress {
* to scanning the whole input, preserving the "Progress table not under a
* `## Progress` heading, or not the first table in the document, still
* resolves" behaviour.
*/
export function locateProgressTable(roadmapContent: string): MarkdownTable | null {
const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im);
let scoped = roadmapContent;
if (progressMatch && progressMatch.index !== undefined) {
const afterHeading = roadmapContent.slice(progressMatch.index);
const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/);
scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading;
}
return findTableWithColumns(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
}
/**
* Derive completed_phases, total_phases, and total_plans from ROADMAP content.
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
*
* The Progress table itself is located by `locateProgressTable` (ADR-2143 §3,
* lifted out as #3227's single-owner extraction) — this function consumes
* that table.
*
* Cells are read by column NAME (`r['Status']`, `r['Plans Complete']`,
* `r['Phase']`), fixing #2137 (the old position-based regex assumed "Status"
@@ -72,20 +94,7 @@ export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgre
// `{ ok: false, reason }`, not an exception — so the catch was masking
// nothing but dead code paths. Removed per ADR-2143 §5; the public
// `RoadmapProgress` contract (nulls = absent) is unchanged.
//
// ADR-2143 §3: read the Progress table by column NAME (order/injection-invariant),
// via the markdown-table seam. Scope to the `## Progress` section when present
// (#2012 decoy avoidance); a headingless milestone slice (#1445) falls back to the
// whole input. Requires the canonical Phase/Plans Complete/Status/Completed columns
// in any order (extra columns ignored) — supersedes findTableBySchema's exact-schema lookup.
const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im);
let scoped = roadmapContent;
if (progressMatch && progressMatch.index !== undefined) {
const afterHeading = roadmapContent.slice(progressMatch.index);
const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/);
scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading;
}
const table = findTableWithColumns(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
const table = locateProgressTable(roadmapContent);
if (table) {
const allRows = table.rows;

View File

@@ -21,6 +21,9 @@ import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
import ioMod = require('./io.cjs');
const { output, error, ERROR_REASON } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateContract = require('./state-contract.cjs');
const { publishStateContract } = stateContract;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
import configLoaderMod = require('./config-loader.cjs');
const { loadConfig } = configLoaderMod;
@@ -1151,6 +1154,18 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
if (titleWarning) result['warning'] = titleWarning;
output(result, raw, result['padded']);
// #3227 (design doc §40 row 26 / "Not-corruption" rule): every
// `publishStateContract` call site in this file is audited so a refreshed
// state.json `updated_at` always means something on disk actually moved —
// a stale-but-refreshed timestamp is worse than no refresh, because it
// reads as fresh to a downstream watcher. This site is unconditional
// because every reachable path either exits via `error()` (process.exit,
// never reaches here) or falls through to the unconditional
// `platformEnsureDir`/`platformWriteSync` pair above that always creates
// the phase directory and rewrites ROADMAP.md — there is no code path that
// reaches this line without having just written to disk. Best-effort —
// cannot throw, cannot change this command's exit code or output.
publishStateContract(cwd);
}
function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): void {
@@ -1238,6 +1253,11 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
return added;
});
output({ phases: results, count: results.length }, raw);
// #3227: unconditional here because `platformWriteSync(roadmapPath, rawContent)`
// above always rewrites ROADMAP.md for every description in the batch before
// this line is reached; the only refusal path is the `error('ROADMAP.md not
// found')` above, which terminates the process and never reaches here.
publishStateContract(cwd);
}
function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, raw: boolean): void {
@@ -1431,6 +1451,11 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra
};
output(result, raw, decimalPhase);
// #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
// above always rewrites ROADMAP.md with the inserted phase before this line is
// reached; every refusal along the way (bad args, missing ROADMAP.md, unresolved
// target bullet/header) exits via `error()`, which terminates the process.
publishStateContract(cwd);
}
interface RenameDirInfo {
@@ -2110,6 +2135,12 @@ function cmdPhaseRemove(
},
raw,
);
// #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
// always rewrites ROADMAP.md before this line is reached; every refusal path
// (bad target, missing ROADMAP.md, --force-required, renumber failure) exits
// via `error()`, and the ambiguous-match case exits via an earlier `return`
// before any file is touched.
publishStateContract(cwd);
}
interface WriteSpec {
@@ -2118,7 +2149,14 @@ interface WriteSpec {
after: string;
}
function writePlanningFileSet(writes: WriteSpec[]): void {
/**
* #3227: returns the count of writes actually applied (entries whose
* `before` differed from `after` and were therefore written to disk) — the
* caller (`cmdPhaseComplete`) uses this as its publish-gate signal, since a
* re-run against an already-completed phase can produce a `writes[]` array
* where every entry is byte-identical to what's already on disk.
*/
function writePlanningFileSet(writes: WriteSpec[]): number {
const applied: WriteSpec[] = [];
try {
for (const write of writes) {
@@ -2145,6 +2183,7 @@ function writePlanningFileSet(writes: WriteSpec[]): void {
}
throw err;
}
return applied.length;
}
function phaseDisplayNameFromRoadmap(roadmapContent: string | null, phaseNum: string | null): string | null {
@@ -2415,6 +2454,15 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// verification_stale_check_indeterminate).
let milestoneConflict: milestoneLockMod.MilestoneConflict | null = null;
// #3227: set inside `runPhaseCompleteTransaction` below from
// `writePlanningFileSet`'s applied-count return — the transaction always
// RUNS (verification passed, the lock was taken, `writes[]` was built),
// but a re-run against a phase whose ROADMAP/STATE bytes already reflect
// completion produces a `writes[]` where every entry is byte-identical to
// disk, so `writePlanningFileSet` applies none of them. That must not
// still refresh state.json's `updated_at` (design doc §40 row 26).
let anyPlanningWrite = false;
const verificationBlocked = withPlanningLock(cwd, () => {
// #3311: completing a phase while a live milestone claim (phase + session)
// holds a DIFFERENT phase means two sessions are working two phases against
@@ -3207,7 +3255,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
}
writePlanningFileSet(writes);
anyPlanningWrite = writePlanningFileSet(writes) > 0;
};
if (fs.existsSync(statePath)) {
@@ -3283,6 +3331,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
};
output(result, raw);
// #3227: gate on `anyPlanningWrite` (whether `writePlanningFileSet`
// actually wrote anything), not on reaching this line — reaching here only
// means verification passed and the transaction ran, not that ROADMAP.md
// or STATE.md bytes changed (see the `anyPlanningWrite` declaration above).
if (anyPlanningWrite) publishStateContract(cwd);
}
function cmdPhaseUatPassed(

407
src/state-contract.cts Normal file
View File

@@ -0,0 +1,407 @@
/**
* State Contract Module — `.planning/state.json` schema v1 (#3227).
*
* A **best-effort publisher** invoked at 11 step-boundary commands
* (`state.*`, `phase.*`, `milestone.complete`). It composes existing
* canonical owners; it introduces no second answer to any question GSD
* already answers:
* - `planning-workspace.cjs`'s `planningPaths` for every `.planning/` path
* (workstream-aware — never re-derived here).
* - `phase-lifecycle.cjs`'s `locateProgressTable` (this issue's Edit 1) for
* "where is the Progress table" — the SAME table
* `deriveProgressFromRoadmap` counts from, so `phases[]` and
* `deriveProgressFromRoadmap`'s `totalPhases` can never disagree about
* which rows are data rows.
* - `phase-id.cjs`'s `isSentinelPhaseId` / `parsePhaseFromProse` for
* sentinel exclusion and STATE.md phase-token parsing.
* - `roadmap-parser.cjs`'s `getMilestoneInfo` for milestone identity.
* - `markdown-sectionizer.cjs`'s `collectSection` for the `## Phases`
* checkbox fallback.
* - `state-document.cjs`'s `stateFieldValue` + `frontmatter.cjs`'s
* `extractFrontmatter`/`stripFrontmatter` for STATE.md's `current_phase`.
* - `shell-command-projection.cjs`'s `platformReadSync`/`platformWriteSync`
* — the CLAUDE.md-designated single OS-facing I/O seam. `platformWriteSync`
* is ALREADY atomic (sibling tmp + `retryRenameSync`, Windows transient-
* lock retry, EXDEV fallback) — this module adds no sixth atomic-write
* helper (the repo already carries five).
* - `smart-entry.cjs`'s `classifyProject` for `next` — this is what makes
* "`next` equals smart-entry's recommended action" true BY CONSTRUCTION
* rather than by a second copy of the routing table.
*
* WHY NOT `PlanningSnapshot` / `buildPlanningInspect`. `PlanningSnapshot` is
* an explicitly-additive INTERNAL diagnostic shape (ADR-3180 §8.1) still
* growing across phases; freezing it as an external contract is the exact
* Hyrum's-Law break `planning.inspect` (`src/planning-inspect.cts`) already
* refused for the same reason (#2790). `state.json` declares its OWN flat,
* minimal, six-key schema instead — see PUBLIC SURFACE below.
*
* NOT built (Gall's Law / Zawinski's Law — see `.gsd/phase/feat-3227-state-
* contract/40-design.md`): a `--watch` mode, a reader API inside gsd-core, a
* diff/event stream, a config toggle, milestone-scoping of `phases[]`. This
* module answers "where is this project right now" and nothing else — a
* request for document *contents* belongs to `planning.inspect`.
*
* REQUIRE-CYCLE HAZARD (the single most important implementation
* constraint). `src/state.cts` imports THIS module, and `src/smart-entry.cts`
* imports `state.cjs` and destructures `readStateHeadFreshness` off it at
* MODULE LOAD time. A top-level `import smartEntry = require('./smart-entry.cjs')`
* here would therefore close the cycle
* `state.cjs -> state-contract.cjs -> smart-entry.cjs -> state.cjs`, binding
* `readStateHeadFreshness` to `undefined` inside `smart-entry.cjs` and
* throwing at its first call. Every owner below is required LAZILY, inside
* the function body, via a small cached loader — never at module top level —
* which also keeps this module's (and its owners') load cost off every
* command that imports `state.cjs` but never reaches a publish boundary.
* Someone will be tempted to "tidy" the lazy requires into top-level ones;
* doing so reopens the cycle.
*
* `withPlanningLock` is deliberately NOT taken around the write (Rejected §3
* of the design doc): it is not re-entrant, several phase commands already
* hold it when they would call this publisher, and `platformWriteSync`'s
* tmp+rename already makes each publish all-or-nothing without it.
*
* ADR-457 build-at-publish: source in src/state-contract.cts, compiled to
* gsd-core/bin/lib/state-contract.cjs (gitignored).
*/
import path from 'node:path';
import fs from 'node:fs';
// ─── Public wire vocabulary ────────────────────────────────────────────────
export const STATE_CONTRACT_VERSION = '1.0.0';
export const STATE_CONTRACT_FLAVOR = 'core';
/** Key order of the emitted contract object — an observable, pinned by test. */
export const CONTRACT_KEY_ORDER = Object.freeze(['contract', 'flavor', 'milestone', 'phases', 'next', 'updated_at']);
/** Key order of each emitted phase object — an observable, pinned by test. */
export const PHASE_KEY_ORDER = Object.freeze(['number', 'name', 'status']);
/** Frozen three-value phase status vocabulary. Conservative in what we send (Postel's Law). */
export const PHASE_STATUS = Object.freeze({
COMPLETE: 'complete',
IN_PROGRESS: 'in_progress',
PENDING: 'pending',
});
/** Frozen `publishStateContract` result reasons. */
export const PUBLISH_REASON = Object.freeze({
PUBLISHED: 'published',
NO_PLANNING_DIR: 'no_planning_dir',
WRITE_FAILED: 'write_failed',
});
export interface StateContractDeps {
now?: () => number;
classify?: (cwd: string) => unknown;
}
interface StateContractPhase {
number: string;
name: string | null;
status: string;
}
interface StateContractNext {
command: string;
label: string;
reason: string;
}
export interface StateContractV1 {
contract: string;
flavor: string;
milestone: string | null;
phases: StateContractPhase[];
next: StateContractNext | null;
updated_at: string;
}
// ─── Lazy owner loader (breaks the state.cjs <-> smart-entry.cjs cycle) ────
interface Owners {
planningPaths: typeof import('./planning-workspace.cjs')['planningPaths'];
platformReadSync: typeof import('./shell-command-projection.cjs')['platformReadSync'];
platformWriteSync: typeof import('./shell-command-projection.cjs')['platformWriteSync'];
locateProgressTable: typeof import('./phase-lifecycle.cjs')['locateProgressTable'];
isSentinelPhaseId: typeof import('./phase-id.cjs')['isSentinelPhaseId'];
parsePhaseFromProse: typeof import('./phase-id.cjs')['parsePhaseFromProse'];
getMilestoneInfo: typeof import('./roadmap-parser.cjs')['getMilestoneInfo'];
collectSection: typeof import('./markdown-sectionizer.cjs')['collectSection'];
stateFieldValue: typeof import('./state-document.cjs')['stateFieldValue'];
extractFrontmatter: typeof import('./frontmatter.cjs')['extractFrontmatter'];
stripFrontmatter: typeof import('./frontmatter.cjs')['stripFrontmatter'];
classifyProject: typeof import('./smart-entry.cjs')['classifyProject'];
}
let _owners: Owners | null = null;
/**
* Lazily require every owner this module composes. MUST stay lazy — see the
* "REQUIRE-CYCLE HAZARD" module-doc comment above. Cached after first call so
* repeat invocations within one process (multiple publishes in one command,
* or across the several boundary commands in a test run) pay the require
* cost once.
*/
function owners(): Owners {
if (_owners) return _owners;
/* eslint-disable @typescript-eslint/no-require-imports */
const planningWorkspaceMod = require('./planning-workspace.cjs') as typeof import('./planning-workspace.cjs');
const shellCommandProjectionMod = require('./shell-command-projection.cjs') as typeof import('./shell-command-projection.cjs');
const phaseLifecycleMod = require('./phase-lifecycle.cjs') as typeof import('./phase-lifecycle.cjs');
const phaseIdMod = require('./phase-id.cjs') as typeof import('./phase-id.cjs');
const roadmapParserMod = require('./roadmap-parser.cjs') as typeof import('./roadmap-parser.cjs');
const markdownSectionizerMod = require('./markdown-sectionizer.cjs') as typeof import('./markdown-sectionizer.cjs');
const stateDocumentMod = require('./state-document.cjs') as typeof import('./state-document.cjs');
const frontmatterMod = require('./frontmatter.cjs') as typeof import('./frontmatter.cjs');
const smartEntryMod = require('./smart-entry.cjs') as typeof import('./smart-entry.cjs');
/* eslint-enable @typescript-eslint/no-require-imports */
_owners = {
planningPaths: planningWorkspaceMod.planningPaths,
platformReadSync: shellCommandProjectionMod.platformReadSync,
platformWriteSync: shellCommandProjectionMod.platformWriteSync,
locateProgressTable: phaseLifecycleMod.locateProgressTable,
isSentinelPhaseId: phaseIdMod.isSentinelPhaseId,
parsePhaseFromProse: phaseIdMod.parsePhaseFromProse,
getMilestoneInfo: roadmapParserMod.getMilestoneInfo,
collectSection: markdownSectionizerMod.collectSection,
stateFieldValue: stateDocumentMod.stateFieldValue,
extractFrontmatter: frontmatterMod.extractFrontmatter,
stripFrontmatter: frontmatterMod.stripFrontmatter,
classifyProject: smartEntryMod.classifyProject,
};
return _owners;
}
// ─── milestone ──────────────────────────────────────────────────────────────
function buildMilestone(cwd: string): string | null {
try {
const { value } = owners().getMilestoneInfo(cwd);
if (!value) return null;
// U+2014 EM DASH, spaced on both sides. Never fabricate a name — version
// alone when no name-bearing evidence resolved (design row 21).
return value.name && value.name.length > 0 ? `${value.version} — ${value.name}` : value.version;
} catch {
return null;
}
}
// ─── phases: Progress-table (primary) enumerator ───────────────────────────
const PHASE_CELL_RE = /^(\d+(?:\.\d+)*)\s*[.:)–—-]?\s*(.*)$/;
function statusFromProgressCell(raw: string | undefined): string {
const normalized = (raw ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
if (normalized === 'complete') return PHASE_STATUS.COMPLETE;
if (normalized === 'in progress') return PHASE_STATUS.IN_PROGRESS;
// Everything else — 'not started', 'deferred' (design row 12, lossy by
// design), '', and any unrecognized word — folds to pending. An
// unrecognized status must never reach the wire as a 4th value.
return PHASE_STATUS.PENDING;
}
function phasesFromProgressTable(
table: { columns: string[]; rows: Record<string, string>[] },
): StateContractPhase[] {
const { isSentinelPhaseId } = owners();
const phases: StateContractPhase[] = [];
for (const row of table.rows) {
const cell = (row['Phase'] ?? '').trim();
if (!/^\d/.test(cell)) continue; // stray note row, no leading digit
if (isSentinelPhaseId(cell)) continue; // phase 0 / 999.x sentinels
const m = PHASE_CELL_RE.exec(cell);
const number = m ? m[1] : cell;
const name = m && m[2].trim().length > 0 ? m[2].trim() : null;
phases.push({ number, name, status: statusFromProgressCell(row['Status']) });
}
return phases;
}
// ─── phases: `## Phases` checkbox fallback ─────────────────────────────────
// `- [x] **Phase 1: Foundation**` / `- [ ] **Phase 2.1: Hardening**`.
const PHASE_BULLET_RE = /^[ \t]*[-*][ \t]+\[([ xX])\][ \t]+\*\*Phase[ \t]+(\d+(?:\.\d+)*)[ \t]*:?[ \t]*([^*\n]*)\*\*/gm;
/**
* STATE.md's current phase, via the same frontmatter-then-body chain
* `cmdStateCompletePhase` uses (`stateFieldValue` owns the #1760 fallback
* ladder). Any failure anywhere in the chain yields `null` — treat current
* phase as unknown so every unchecked bullet is PENDING rather than
* IN_PROGRESS by a guess.
*/
function currentPhaseFromState(statePath: string): string | null {
const { platformReadSync, extractFrontmatter, stripFrontmatter, stateFieldValue, parsePhaseFromProse } = owners();
try {
const raw = platformReadSync(statePath);
if (raw === null) return null;
const fm = extractFrontmatter(raw, statePath);
const body = stripFrontmatter(raw);
const { value } = stateFieldValue(fm, body, 'current_phase', 'Current Phase');
if (!value) return null;
return parsePhaseFromProse(value).phase;
} catch {
return null;
}
}
/**
* Fallback enumerator when the ROADMAP carries no Progress table.
*
* Scoping to `## Phases` is load-bearing negative space (design "Not-
* corruption" section): it is what keeps `- [x] 01-01: a plan` (a PLAN
* bullet under `## Phase Details`) and `- ✅ **v1.0 MVP** - Phases 1-4`
* (a MILESTONE bullet under `## Milestones`) from being read as phases. The
* `**Phase N` anchor inside `PHASE_BULLET_RE` is the second layer of that
* defence — a plan bullet's text never starts with `**Phase`.
*/
function phasesFromChecklist(roadmapContent: string, cwd: string, statePath: string): StateContractPhase[] {
const { collectSection, isSentinelPhaseId } = owners();
const section = collectSection(roadmapContent, (h) => /^phases$/i.test(h.text.trim()));
if (!section) return [];
const currentPhase = currentPhaseFromState(statePath);
const phases: StateContractPhase[] = [];
let match: RegExpExecArray | null;
const pattern = new RegExp(PHASE_BULLET_RE.source, PHASE_BULLET_RE.flags);
while ((match = pattern.exec(section.body)) !== null) {
const checked = match[1] === 'x' || match[1] === 'X';
const number = match[2];
if (isSentinelPhaseId(number)) continue;
const name = match[3].replace(/\r$/, '').trim();
const status = checked
? PHASE_STATUS.COMPLETE
: (currentPhase !== null && number === currentPhase ? PHASE_STATUS.IN_PROGRESS : PHASE_STATUS.PENDING);
phases.push({ number, name: name.length > 0 ? name : null, status });
}
return phases;
}
function buildPhases(cwd: string, paths: { roadmap: string; state: string }): StateContractPhase[] {
const { platformReadSync, locateProgressTable } = owners();
try {
let content = platformReadSync(paths.roadmap);
if (content === null) return [];
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1); // strip leading BOM
const table = locateProgressTable(content);
if (table) return phasesFromProgressTable(table);
return phasesFromChecklist(content, cwd, paths.state);
} catch {
return [];
}
}
// ─── next ───────────────────────────────────────────────────────────────────
function buildNext(cwd: string, deps?: StateContractDeps): StateContractNext | null {
try {
const classify = (deps?.classify as Owners['classifyProject'] | undefined) ?? owners().classifyProject;
const result = classify(cwd);
const action = result.actions.find((a) => a.id === result.recommended);
if (!action) return null;
return { command: action.command, label: action.label, reason: result.summary };
} catch {
return null;
}
}
// ─── Entry points ───────────────────────────────────────────────────────────
/**
* Build the state-contract snapshot. PURE — writes nothing. Every stage is
* independently try/caught (milestone, phases, next) so one bad document
* cannot lose the others; `updated_at` and the frozen `contract`/`flavor`
* keys always resolve.
*/
export function buildStateContract(cwd: string, deps?: StateContractDeps): StateContractV1 {
const nowFn = deps?.now ?? Date.now;
const updated_at = new Date(nowFn()).toISOString();
// #2245 shape: `planningPaths` -> `planningDir` throws a plain Error for an
// invalid GSD_WORKSTREAM/GSD_PROJECT segment. This call must not be hoisted
// outside a try, or that throw escapes uncaught and breaks the invariant
// this function is documented as upholding (see `getMilestoneInfo`'s
// equivalent guard comment in src/roadmap-parser.cts for the same shape).
let paths: { roadmap: string; state: string } | null;
try {
paths = owners().planningPaths(cwd);
} catch {
paths = null;
}
if (!paths) {
return {
contract: STATE_CONTRACT_VERSION,
flavor: STATE_CONTRACT_FLAVOR,
milestone: null,
phases: [],
next: null,
updated_at,
};
}
const milestone = buildMilestone(cwd);
const phases = buildPhases(cwd, paths);
const next = buildNext(cwd, deps);
return {
contract: STATE_CONTRACT_VERSION,
flavor: STATE_CONTRACT_FLAVOR,
milestone,
phases,
next,
updated_at,
};
}
/**
* Publish the state-contract snapshot to `<planning>/state.json`. NEVER
* throws — for any input, ever (acceptance criterion 3: the parent boundary
* command's exit code and stdout must be unchanged whatever happens here).
*
* No `.planning/` directory at all: writes nothing, creates nothing. This
* guard is essential — `platformWriteSync` does `mkdirSync(dirname,
* {recursive:true})`, so without it the publisher would conjure a
* `.planning/` tree inside a non-GSD directory.
*
* `withPlanningLock` is deliberately NOT taken here — see the module-doc
* "REQUIRE-CYCLE HAZARD" section's sibling note: it is not re-entrant and
* several phase commands already hold it when they call this publisher, so
* acquiring it here would throw in exactly the situation this function must
* never perturb. `platformWriteSync`'s sibling-tmp + rename already makes
* each publish all-or-nothing without it.
*/
export function publishStateContract(cwd: string, deps?: StateContractDeps): { published: boolean; reason: string } {
try {
const { planningPaths, platformWriteSync } = owners();
// Resolve the planning root BEFORE anything else. A resolution failure
// (invalid GSD_WORKSTREAM/GSD_PROJECT segment — the #2245 shape) means no
// write was ever attempted, so it must map to `no_planning_dir`, not
// `write_failed` — that reason is reserved for an attempted-and-failed
// write, and reporting it here would send a caller looking at disk
// permissions for a problem that isn't there.
let paths: { planning: string };
try {
paths = planningPaths(cwd);
} catch {
return { published: false, reason: PUBLISH_REASON.NO_PLANNING_DIR };
}
if (!fs.existsSync(paths.planning)) {
return { published: false, reason: PUBLISH_REASON.NO_PLANNING_DIR };
}
const snapshot = buildStateContract(cwd, deps);
const statePath = path.join(paths.planning, 'state.json');
try {
platformWriteSync(statePath, JSON.stringify(snapshot, null, 2) + '\n');
} catch {
return { published: false, reason: PUBLISH_REASON.WRITE_FAILED };
}
return { published: true, reason: PUBLISH_REASON.PUBLISHED };
} catch {
return { published: false, reason: PUBLISH_REASON.WRITE_FAILED };
}
}

View File

@@ -13,6 +13,9 @@ import { escapeRegex } from './pattern.cjs';
import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateContract = require('./state-contract.cjs');
const { publishStateContract } = stateContract;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -677,7 +680,7 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
// STATE.md lock, so the position read and the claim read cannot interleave
// with another session's Current Position write.
let milestoneConflict: milestoneLockMod.MilestoneConflict | null = null;
readModifyWriteStateMd(statePath, (content) => {
const wrote = readModifyWriteStateMd(statePath, (content) => {
// advance-plan has no phase argument of its own — the phase it advances is
// whatever ## Current Position names. Compare that against the milestone
// claim: a mismatch means another session moved the single-slot position
@@ -717,6 +720,17 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
} else {
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
}
// #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
// state.json `updated_at` must always mean something on disk actually
// moved, in EITHER branch above — so gate on `wrote`
// (readModifyWriteStateMd's own return value) rather than assuming both
// branches are unconditional mutations. They are not: re-running
// advance-plan on a phase already parked in its post-advance state (e.g.
// two same-day calls once a phase is "ready for verification") reproduces
// byte-identical content, the #948 no-op guard skips the write, and
// `resultData`/`updated` still populate normally from the transform's OWN
// (unwritten) output — so those are not safe publish signals here either.
if (wrote) publishStateContract(cwd);
}
function cmdStateRecordMetric(cwd: string, options: StateRecordMetricOptions, raw: boolean): void {
@@ -3762,7 +3776,7 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
// phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
// calls cannot both read "no claim" and both write.
let milestoneConflict: milestoneLockMod.MilestoneConflict | null = null;
readModifyWriteStateMd(statePath, (content) => {
const wrote = readModifyWriteStateMd(statePath, (content) => {
milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
if (milestoneConflict) {
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
@@ -3791,6 +3805,26 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
raw,
updated.length > 0 ? 'true' : 'false',
);
// #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on `wrote`
// (readModifyWriteStateMd's own return value — its #948 no-op guard skips
// the write outright when the transform produced no diff), not on
// `updated.length > 0`. Confirmed reproducer: an unrecognized-format
// STATE.md makes `beginPhaseCore` match zero body fields AND leave
// `existingFm` untouched, so the raw transform output is byte-identical to
// the input, the RMW guard fires, and `wrote` is false — matching
// `updated: []` here. Unlike `cmdStatePlannedPhase` (which must NOT use
// this same `wrote` signal — see its comment for why `plannedPhaseCore`
// mutates frontmatter in place even on this exact no-op shape),
// `beginPhaseCore` never mutates `existingFm`, so `wrote` and
// `updated.length > 0` agree on every case audited for this phase; `wrote`
// is kept as the gate here (and on `cmdStateAdvancePlan`/
// `cmdStateCompletePhase` below, where it is REQUIRED — `updated`/
// `reconciled` can be non-empty there even when nothing was written,
// confirmed by direct re-invocation) for one consistent rule across every
// RMW-backed command in this file: publish iff `readModifyWriteStateMd`
// itself reports a write. Best-effort — cannot throw, cannot change this
// command's exit code or output.
if (wrote) publishStateContract(cwd);
}
/**
@@ -4079,6 +4113,29 @@ function cmdStatePlannedPhase(cwd: string, phaseNumber: string | number, phaseNa
? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
: { updated, phase: phaseNumber, plan_count: planCount };
output(result, raw, updated.length > 0 ? 'true' : 'false');
// #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on
// `updated.length > 0`, NOT on `readModifyWriteStateMd`'s own write-happened
// return value. The two are NOT equivalent here: readModifyWriteStateMd's
// #948 no-op guard compares the transform's RAW returned string against the
// RAW original file content, but `syncStateFrontmatter`'s progress-block
// sync and this command's `authoritativeFm: {current_phase_name}` override
// both run INSIDE the transform (via `frontmatterMod.reconstructFrontmatter`
// over `existingFm`), so an unrecognized-format STATE.md — zero fields the
// transition could actually apply, `updated: []`, the "transition was a
// no-op" warning above — can still make the raw returned string differ
// from the input (frontmatter gets synthesized: `gsd_state_version`,
// `last_updated`, a zeroed `progress` block, `current_phase_name`), so the
// RMW guard does NOT fire and a real write happens. That write is not a
// meaningful state transition by this command's OWN reporting contract
// (`updated: []`) — publishing on it would refresh state.json's
// `updated_at` for a call this command itself reports did nothing.
// `updated.length > 0` is the field-classification-table-backed signal
// that actually answers "did plannedPhaseCore itself change anything this
// caller asked it to change" — empirically verified: an unrecognized-format
// STATE.md reproduces `updated: []` with a genuine (frontmatter-only) disk
// write underneath it, and gating on `updated.length > 0` is what makes
// this reproducer NOT publish.
if (updated.length > 0) publishStateContract(cwd);
}
/**
@@ -4104,6 +4161,7 @@ function cmdStateMilestoneSwitch(cwd: string, version: string | undefined, name:
const intent: StateTransitionIntent = { kind: 'milestoneSwitch', version, name: resolvedName };
const deps: StateTransitionDeps = { clock: realClock, sourcePath: statePath };
let switched = false;
const lockPath = acquireStateLock(statePath);
try {
const content = platformReadSync(statePath) || '';
@@ -4114,9 +4172,15 @@ function cmdStateMilestoneSwitch(cwd: string, version: string | undefined, name:
raw,
'true',
);
switched = true;
} finally {
releaseStateLock(lockPath);
}
// #3227: publish AFTER releaseStateLock — publishStateContract derives `next`
// from classifyProject, which shells out to git (bounded, but up to 3 x 10s).
// Holding the STATE.md lock across that would turn a millisecond hold into a
// git-bound one for every concurrent GSD process.
if (switched) publishStateContract(cwd);
}
/**
@@ -4969,7 +5033,7 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
let preSyncContent = '';
const divergedFields: string[] = [];
readModifyWriteStateMd(statePath, (content) => {
const wrote = readModifyWriteStateMd(statePath, (content) => {
const currentPhase = resolvedPhase;
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
@@ -5078,6 +5142,14 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
raw,
reconciled.length > 0 ? 'true' : 'false',
);
// #3227: gate on `wrote` (readModifyWriteStateMd's own return value), not
// `reconciled.length > 0` — a re-run of complete-phase against a phase
// that is ALREADY marked complete (same status/date/Current Position
// values already on disk) still has `stateReplaceField` report a match for
// every field it looks up, so `reconciled` is non-empty even though the
// #948 no-op guard skipped the write. Same reasoning as
// cmdStateBeginPhase/cmdStatePlannedPhase/cmdStateAdvancePlan above.
if (wrote) publishStateContract(cwd);
}
export = {

View File

@@ -23,7 +23,7 @@ describe('CANONICAL_EXACT', () => {
const expected = [
'PROJECT.md', 'ROADMAP.md', 'STATE.md', 'REQUIREMENTS.md',
'MILESTONES.md', 'BACKLOG.md', 'LEARNINGS.md', 'THREADS.md',
'config.json', 'CLAUDE.md', 'RETROSPECTIVE.md', 'WINDOWS.md',
'config.json', 'CLAUDE.md', 'RETROSPECTIVE.md', 'WINDOWS.md', 'state.json',
];
for (const name of expected) {
assert.ok(CANONICAL_EXACT.has(name), `expected ${name} in CANONICAL_EXACT`);
@@ -58,6 +58,15 @@ describe('isCanonicalPlanningFile', () => {
assert.strictEqual(isCanonicalPlanningFile('config.json'), true);
});
test('#3227: state.json (machine-readable state contract) is a canonical .planning/ artifact', () => {
// gsd-core writes .planning/state.json at every step boundary
// (src/state-contract.cts). Before #3227 it was absent from the
// registry, so validate health would falsely flag it W019
// "Unrecognized" on every GSD project.
assert.ok(CANONICAL_EXACT.has('state.json'), 'state.json must be in CANONICAL_EXACT');
assert.strictEqual(isCanonicalPlanningFile('state.json'), true);
});
test('#3224: WINDOWS.md (broken-windows ledger) is a canonical .planning/ artifact', () => {
// gsd-core itself writes .planning/WINDOWS.md (src/broken-windows.cts,
// LEDGER_FILE_NAME = 'WINDOWS.md'). Before #3224 it was absent from the
@@ -188,6 +197,18 @@ describe('gsd-health W019 — unrecognized .planning/ root files', () => {
assert.strictEqual(w019, undefined, 'no W019 for canonical files');
});
test('#3227: no W019 for .planning/state.json (state contract published at step boundaries)', () => {
const dir = makeTempProject({
...BASE_FILES,
'.planning/state.json': '{}',
});
const result = cmdValidateHealth(dir, { repair: false }, false);
const w019 = result.warnings.find(w => w.code === 'W019');
assert.strictEqual(w019, undefined, 'state.json must not be flagged as unrecognized');
});
test('no W019 for phase subdirectory files (only root is checked)', () => {
const dir = makeTempProject({
...BASE_FILES,

View File

@@ -331,6 +331,42 @@ describe('testEffectivePrefix', () => {
test('double-numbered stamp is stripped correctly', () => {
assert.strictEqual(testEffectivePrefix('bug-2550-2552-discuss-phase-context.test.cjs'), 'discuss-phase-context');
});
// -------------------------------------------------------------------
// #3227: trailing suite/kind qualifier stripping
// -------------------------------------------------------------------
test('dotted suite-qualifier file resolves to the bare module prefix', () => {
assert.strictEqual(testEffectivePrefix('frontmatter.property.test.cjs'), 'frontmatter');
});
test('unqualified file with a hyphenated module name is unaffected (no-op)', () => {
assert.strictEqual(testEffectivePrefix('state-contract.test.cjs'), 'state-contract');
});
test('pre-existing .integration.test. strip still works after the qualifier strip', () => {
assert.strictEqual(
testEffectivePrefix('installer-migration-install.integration.test.cjs'),
'installer-migration-install'
);
});
});
// ---------------------------------------------------------------------------
// #3227: dotted suite-qualifier files must not mis-bucket into a shorter,
// hyphen-related module (e.g. state-contract.unit.test.cjs -> state).
// ---------------------------------------------------------------------------
describe('_buildTestMap — dotted suite-qualifier bucketing (#3227)', () => {
test('state-contract.unit.test.cjs buckets to state-contract, not the shorter state module', () => {
const testFile = makeFiles('state-contract', ['state-contract.unit.test.cjs'])[0];
const prodPrefixes = new Map([
['state', '/fake/src/state.cjs'],
['state-contract', '/fake/src/state-contract.cjs'],
]);
const map = _buildTestMap(prodPrefixes, [testFile]);
assert.deepStrictEqual(map.get('state-contract'), [testFile]);
assert.deepStrictEqual(map.get('state'), []);
});
});
// ---------------------------------------------------------------------------

View File

@@ -203,6 +203,10 @@ const RATCHET_BASELINE = {
'plan-document': 75, // CI run 32392791843: 76.58% (unit shard)
'planning-command-router': 94, // CI run 32392791843: 95.65% (unit shard); already exceeds TARGET 80
'model-catalog': 58, // #3007: measured 59.62% in CI (248 killed / 168 survived); floor(59.62)-1
'state-contract': 65, // #3227: CI run 32769289750, job 97565813640,
// `Stryker (state-contract)`: measured 66.25%; floor(66.25)-1.
// Below TARGET_MUTATION_SCORE (80) — ratchet candidate like
// planning-inspect / model-catalog above; raise as tests improve.
};
describe('mutation-matrix ratchet: floor equality enforcement', () => {

View File

@@ -0,0 +1,429 @@
'use strict';
/**
* Integration tests for `src/state-contract.cts` — the v1
* `.planning/state.json` best-effort publisher (#3227).
*
* This file owns the CLI / call-site wiring band only: it spawns the real
* `gsd-tools` binary via `runGsdTools` for each of the 11 documented
* step-boundary commands (plus the non-boundary / no-planning / idempotent /
* error-path guard cases) and asserts state.json actually lands on disk
* end-to-end through the real dispatch path. The spawn-free, in-process
* parsing/mapping/degradation/property-based surface — everything that does
* NOT need a child process — lives in the `.unit.` sibling
* `tests/state-contract.unit.test.cjs`, which is also the Stryker mutation
* shard target (`scripts/mutation-matrix.cjs`, `state-contract` entry):
* Stryker's command runner treats one `node --test <file>` invocation as a
* single test costing whatever its slowest case costs, re-run once per
* mutant, so a suite that spawns a child process per case cannot finish
* inside the 15-minute shard cap (#2790 precedent).
*
* Design: .gsd/phase/feat-3227-state-contract/40-design.md
* Test matrix: .gsd/phase/feat-3227-state-contract/50-test-matrix.md
*
* Fixture provenance (CONTRIBUTING.md "Fixture provenance (#2371)"): every
* `.planning/` document shape written by this file's fixture builders is
* derived from the SHIPPED templates the product author wrote —
* `gsd-core/templates/roadmap.md` (`## Phases` checkbox bullets,
* `### Phase N: Name` details with `Plans:` lists, the 4-column and
* milestone-grouped 5-column `## Progress` tables, and the
* `Not started | In progress | Complete | Deferred` status vocabulary) and
* `gsd-core/templates/state.md` (`## Current Position`, `Phase: X of Y
* (Name)`) — never from `state-contract.cts`'s own parsing model.
*
* This module is a NEW leaf; the fixture builders below are local to this
* file rather than reused from `tests/planning-inspect.test.cjs` (a sibling
* document-shape consumer) because the shapes this suite needs diverge
* enough from that file's `## Phase Details` + `Plans:` fixtures that
* sharing would couple two independent test suites to one mutable helper.
*
* These same fixture helpers are also byte-duplicated (not shared) into the
* `.unit.` sibling above, deliberately: that file is the Stryker mutation
* shard target and must stay spawn-free and self-contained, so a `require`
* of this integration file would drag `runGsdTools` and its subprocess seam
* into the shard. The duplication is isolation, not drift.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
const { STATE_CONTRACT_VERSION } = require('../gsd-core/bin/lib/state-contract.cjs');
// ─── Fixture helpers ──────────────────────────────────────────────────────────
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function statePathOf(cwd) {
return path.join(planningDirOf(cwd), 'state.json');
}
function writeAbs(fullPath, content) {
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
function writeFile(cwd, relPath, content) {
writeAbs(path.join(cwd, relPath), content);
}
function writeRoadmapRaw(cwd, content) {
writeFile(cwd, '.planning/ROADMAP.md', content);
}
function writeRoadmap(cwd, lines, eol = '\n') {
writeRoadmapRaw(cwd, lines.join(eol));
}
function writeState(cwd, frontmatterLines, bodyLines = [], eol = '\n') {
writeFile(cwd, '.planning/STATE.md', ['---', ...frontmatterLines, '---', '', ...bodyLines].join(eol));
}
function readStateJsonRaw(cwd) {
return fs.readFileSync(statePathOf(cwd), 'utf8');
}
// ─── 14. Call-site wiring (integration, real CLI) ──────────────────────────────
function writePassedVerification(tmpDir, phaseDirName, phaseToken) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseDirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, `${phaseToken}-VERIFICATION.md`),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
return phaseDir;
}
const BOUNDARY_COMMANDS = [
{
label: 'state begin-phase',
argv: ['state', 'begin-phase', '--phase', '2', '--name', 'Hardening', '--plans', '2'],
setup: (tmpDir) => {
// Body-only STATE.md (no frontmatter block) — begin-phase reads its
// preconditions from the `**Current Phase:**`-style body fields.
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), [
'# Project State', '',
'**Current Phase:** 2',
'**Current Phase Name:** Hardening',
'**Total Phases:** 3',
'**Current Plan:** 0',
'**Total Plans in Phase:** 0',
'**Status:** Ready to plan',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** setup',
'',
].join('\n'));
},
},
{
label: 'state planned-phase',
argv: ['state', 'planned-phase', '--phase', '3', '--name', 'Polish', '--plans', '1'],
setup: (tmpDir) => {
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# Project State\n\n**Status:** Planning\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 3\n');
},
},
{
label: 'state advance-plan',
argv: ['state', 'advance-plan'],
setup: (tmpDir) => {
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), [
'# Project State', '',
'**Current Plan:** 1',
'**Total Plans in Phase:** 3',
'**Status:** Executing',
'**Last Activity:** 2024-01-10',
'',
].join('\n'));
},
},
{
label: 'state complete-phase',
argv: ['state', 'complete-phase', '--phase', '2'],
setup: (tmpDir) => {
writeState(tmpDir, ['milestone: v1.0', 'current_phase: 2'], [
'# State', '',
'**Status:** Executing',
'**Last Activity:** 2024-01-15',
'',
]);
},
},
{
label: 'state milestone-switch',
argv: ['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'Next'],
setup: (tmpDir) => {
writeState(tmpDir, [
"gsd_state_version: '1.0'", 'milestone: v1.0', 'milestone_name: Foundation', 'status: completed',
], [
'# Project State', '', '## Current Position', '',
'Phase: 5 (Foundation) — COMPLETED', 'Plan: 3 of 3',
'Status: v1.0 milestone complete', 'Last activity: 2025-01-01 -- v1.0 shipped', '',
]);
writeRoadmap(tmpDir, ['# Roadmap', '', '## v1.0 Foundation', '', '### Phase 5: Notify', '']);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'config.json'), '{}');
},
},
{
label: 'phase add',
argv: ['phase', 'add', 'A new phase'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, ['# Roadmap v1.0', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '---', '']);
},
},
{
label: 'phase add-batch',
argv: ['phase', 'add-batch', '--descriptions', '["One","Two"]'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, ['# Roadmap v1.0', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '---', '']);
},
},
{
label: 'phase insert',
argv: ['phase', 'insert', '2', 'An inserted phase'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, [
'# Roadmap', '',
'### Phase 1: Foundation', '**Goal:** Setup', '**Depends on:** Nothing', '',
'### Phase 2: Auth', '**Goal:** Authentication', '**Depends on:** Phase 1', '',
]);
},
},
{
label: 'phase remove',
argv: ['phase', 'remove', '3', '--force'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, [
'# Roadmap', '',
'### Phase 1: Foundation', '**Goal:** Setup', '**Depends on:** Nothing', '',
'### Phase 2: Auth', '**Goal:** Authentication', '**Depends on:** Phase 1', '',
'### Phase 3: Features', '**Goal:** Core features', '**Depends on:** Phase 2', '',
]);
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '03-features'), { recursive: true });
},
},
{
label: 'phase complete',
argv: ['phase', 'complete', '1'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, [
'# Roadmap', '',
'- [ ] Phase 1: Foundation', '- [ ] Phase 2: API', '',
'### Phase 1: Foundation', '**Goal:** Setup', '**Plans:** 1 plans', '',
'### Phase 2: API', '**Goal:** Build API', '',
]);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n');
const p1 = path.join(planningDirOf(tmpDir), 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '02-api'), { recursive: true });
writePassedVerification(tmpDir, '01-foundation', '01');
},
},
{
label: 'milestone complete',
argv: ['milestone', 'complete', 'v1.1', '--force'],
setup: (tmpDir) => {
// --force bypasses both the TRUNCATED-scope guard and the
// unstarted-phase (no_directory) guard, so this fixture only needs a
// resolvable v1.1 milestone window with at least one phase entry —
// the one precondition `getMilestonePhaseFilter`'s
// `missingExplicitVersion` check enforces unconditionally, before
// --force is ever consulted.
writeRoadmap(tmpDir, ['# Roadmap', '', '## v1.1 Hardening', '', '### Phase 1: Foo', '**Goal:** Ship it', '']);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# Project State\n\n**Status:** Executing\n**Current Phase:** 1\n**Last Activity:** 2025-01-01\n');
},
},
];
describe('state contract — call-site wiring (integration, real CLI)', () => {
test('eachBoundaryCommandPublishesTheSnapshot', () => {
for (const { label, argv, setup } of BOUNDARY_COMMANDS) {
const tmpDir = createTempProject();
setup(tmpDir);
const result = runGsdTools(argv, tmpDir);
assert.strictEqual(result.success, true, `${label} fixture must genuinely succeed: ${result.error}`);
const jsonPath = statePathOf(tmpDir);
assert.strictEqual(fs.existsSync(jsonPath), true, `${label} must publish state.json`);
const onDisk = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
assert.strictEqual(onDisk.contract, STATE_CONTRACT_VERSION, `${label}: state.json contract must equal STATE_CONTRACT_VERSION`);
cleanup(tmpDir);
}
});
test('nonBoundaryCommandDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), '# State\n\n**Status:** Planning\n');
runGsdTools(['state', 'get', 'status'], tmpDir);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false);
});
test('boundaryCommandUnaffectedWithoutPlanning', (t) => {
const dirA = createTempDir();
t.after(() => cleanup(dirA));
const dirB = createTempDir();
t.after(() => cleanup(dirB));
const argv = ['state', 'begin-phase', '--phase', '1', '--name', 'X', '--plans', '1'];
const resultA = runGsdTools(argv, dirA);
const resultB = runGsdTools(argv, dirB);
assert.strictEqual(resultA.success, resultB.success);
assert.strictEqual(resultA.output, resultB.output);
});
test('idempotentNoOpDoesNotRepublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# Project State\n\n**Status:** Executing\n**Current Phase:** 1\n**Last Activity:** 2024-01-01\n');
const first = runGsdTools(['state', 'complete-phase', '--phase', '1'], tmpDir);
assert.ok(first.success, `first complete-phase call failed: ${first.error}`);
assert.ok(fs.existsSync(statePathOf(tmpDir)), 'first (genuine) completion must publish');
const beforeSecondCall = readStateJsonRaw(tmpDir);
const second = runGsdTools(['state', 'complete-phase', '--phase', '1'], tmpDir);
assert.ok(second.success, `second (idempotent) complete-phase call failed: ${second.error}`);
const afterSecondCall = readStateJsonRaw(tmpDir);
assert.strictEqual(afterSecondCall, beforeSecondCall, 'idempotent no-op must not republish (updated_at must not move)');
});
test('errorPathDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
// No STATE.md at all — complete-phase's own precondition check fails.
const result = runGsdTools(['state', 'complete-phase', '--phase', '3'], tmpDir);
assert.ok(result.success, `command should exit 0 with a JSON error envelope: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error, 'expected a structured error envelope');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false);
});
// #3227 blocker fix: `updated: []` genuine no-op transitions must not
// publish state.json — a refreshed `updated_at` must always mean something
// on disk actually moved (design doc §40 row 26). Each case below is a
// reproducer confirmed (via direct CLI probing) to reach
// `publishStateContract` on the unfixed code with nothing genuinely written.
test('plannedPhaseNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, [
'# Roadmap', '',
'## Progress', '',
'| Phase | Plans Complete | Status | Completed |',
'|---|---|---|---|',
'| 1. A | 0/1 | Not started | - |',
'',
]);
writeState(tmpDir, ['status: planning'], [
'# Project State', '',
'(no recognized labels here)',
]);
const result = runGsdTools(['state', 'planned-phase', '--phase', '3', '--name', 'Polish', '--plans', '1'], tmpDir);
assert.ok(result.success, `planned-phase fixture must genuinely succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated, [], 'expected a genuine no-op (zero recognized Current Position labels)');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'a no-op transition must not publish state.json');
});
test('beginPhaseNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, ['# Roadmap', '']);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), [
'# Project State', '',
'(no recognized labels here)',
'',
].join('\n'));
const result = runGsdTools(['state', 'begin-phase', '--phase', '3', '--name', 'Polish', '--plans', '1'], tmpDir);
assert.ok(result.success, `begin-phase fixture must genuinely succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated, [], 'expected a genuine no-op (zero recognized body fields)');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'a no-op transition must not publish state.json');
});
// Additional no-op path found while auditing `state advance-plan`
// (cmdStateAdvancePlan, src/state.cts): re-invoking advance-plan while
// already parked at "last plan, ready for verification" reproduces
// byte-identical STATE.md content on the SECOND call — `updated` still
// reports `Status`/`Last Activity` (reconcileReportedFields matches
// against the value ALREADY persisted from the first call), but nothing
// is written on the second call, so publishing on `updated.length > 0`
// alone would be wrong here; the fix gates on
// `readModifyWriteStateMd`'s own write-happened return value instead.
test('advancePlanNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, ['# Roadmap', '']);
writeState(tmpDir, ['status: planning'], [
'# Project State', '',
'Current Plan: 2', 'Total Plans in Phase: 2', 'Status: Ready to execute', 'Last Activity: 2020-01-01',
'',
'## Current Position',
'Phase: 5', 'Plan: 2 of 2', 'Status: Ready to execute',
'',
]);
const first = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.ok(first.success, `first advance-plan call must genuinely succeed: ${first.error}`);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), true, 'first (genuine) advance must publish');
fs.unlinkSync(statePathOf(tmpDir));
const second = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.ok(second.success, `second (no-op) advance-plan call must genuinely succeed: ${second.error}`);
const output = JSON.parse(second.output);
assert.ok(Array.isArray(output.updated) && output.updated.length > 0, 'reconciled updated[] is non-empty even though nothing was written this call');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'the second, no-op advance-plan call must not publish state.json');
});
// Additional no-op path found while auditing `phase complete`
// (cmdPhaseComplete, src/phase.cts): with no STATE.md present, a re-run of
// `phase complete <N>` against an already-completed phase produces a
// `writes[]` whose only entry (ROADMAP.md) is byte-identical to what's
// already on disk — `writePlanningFileSet` applies zero writes on the
// second call, so the fix gates on that applied count rather than
// publishing unconditionally once the verification-gated transaction runs.
test('phaseCompleteReRunWithoutStateNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, [
'# Roadmap', '',
'### Phase 1: Foo', '',
'**Goal:** g', '**Plans:** 1 plans', '',
'Plans:', '- [ ] 01 TBD', '',
'## Progress', '',
'| Phase | Plans Complete | Status | Completed |',
'|---|---|---|---|',
'| 1 | 0/1 | Not started | - |',
'',
]);
if (fs.existsSync(path.join(planningDirOf(tmpDir), 'STATE.md'))) fs.unlinkSync(path.join(planningDirOf(tmpDir), 'STATE.md'));
const phaseDir = path.join(planningDirOf(tmpDir), 'phases', '01-foo');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-SUMMARY.md'), '# Summary');
writePassedVerification(tmpDir, '01-foo', '01');
const first = runGsdTools(['phase', 'complete', '1'], tmpDir);
assert.ok(first.success, `first phase complete call must genuinely succeed: ${first.error}`);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), true, 'first (genuine) completion must publish');
fs.unlinkSync(statePathOf(tmpDir));
const second = runGsdTools(['phase', 'complete', '1'], tmpDir);
assert.ok(second.success, `second (no-op) phase complete call must genuinely succeed: ${second.error}`);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'the second, no-op phase complete call must not publish state.json');
});
});

File diff suppressed because it is too large Load Diff