Files
msd-core/docs/how-to/consume-the-state-contract.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

9.1 KiB

Consume the state contract

You are building something that shows where a MSD project stands — a workbench, a dashboard, a status bar, an editor extension. .planning/state.json gives you that as one small JSON file that MSD 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. MSD 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. MSD 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, each workstream has its own planning root and therefore its own snapshot:

<project>/.planning/workstreams/<name>/state.json

2. Check the contract version first

{ "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:

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 MSD 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 MSD 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 /msd-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. "/msd-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:

{
  "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": "/msd-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 MSD 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. MSD 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 MSD 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 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 MSD 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. MSD deliberately does not create .planning/ in order to publish — a directory that is not a MSD project stays untouched. Then run any boundary command (msd-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. MSD_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 MSD 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 MSD'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. MSD 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.