Files
msd-core/docs/how-to/consume-the-planning-snapshot.md
Tom Boucher 8da2dd3ad2 feat(#2790): add read-only planning.inspect schema-v1 snapshot query (#3708)
* feat(#2790): add read-only planning.inspect schema-v1 snapshot query

Adds a read-only query emitting a schema-versioned JSON projection of .planning/
so downstream harness UIs can consume planning state without parsing GSD's
Markdown a second time.

Composed strictly from the ADR-3180 section 7 owners plus parsePlanDocument,
parseRequirements and parseUatItems; markdown structure is read through the
Markdown Sectionizer and Markdown Table Model seams. It declares its own flat
external schema rather than serializing PlanningSnapshot, which is the
diagnostic-rule subject and still growing.

Extracts plan-document parsing out of cmdPhasePlanIndex into a shared leaf
module so phase.plan-index and planning.inspect cannot drift, including the
plan-id derivation both surfaces report.

Also fixes parseRequirements dropping the separator delimiter used by the
shipped requirements template, surfaced while wiring the requirement rows.

* fix(#2790): close spec gaps and a raw-text test assertion found in review

Review findings from the standards, spec and security passes:

- phases[] rows carry goal and dependencies, the two per-phase elements the
  issue Summary names that had no corresponding field. Goal is bounded to the
  section's leading prose so the Depends-on line, the Plans checklist and the
  wave annotations are not duplicated into it.
- requirement rows carry their own diagnostic codes, so a consumer no longer
  has to string-parse the global diagnostics subject to correlate.
- roadmap_acceptance.checkbox is looked up through the phase-id key owners.
  It was compared raw against the on-disk directory name, so it read null for
  every real-world slugged phase directory and the evidence channel was inert.
- the hostile-input test asserts the structured payload instead of matching the
  raw stdout string. The absence proof over raw stdout is kept deliberately.

* fix(#2790): register planning in the runtime usage list and repair fixtures

Remote runner reported 9 failures on 9b3f9aa. Two root causes, both fixed:

- gsd-tools.cjs registered the planning family in HOST_COMMAND_ROUTERS but
  never added it to TOP_LEVEL_USAGE's Commands list. Those are two surfaces a
  parity test guards, and the top-of-file block comment is not the runtime
  help string. A real wiring gap that every local gate and three review passes
  missed.

- the new suite's fixtures could not produce a resolvable phase set. STATE.md
  frontmatter omitted the milestone field, which ADR-3180 7.2 rule 1 makes the
  primary milestone selector, so the phase set scoped unscoped and every
  percentage was correctly withheld. Separately declarePhase returned a path
  without creating the directory, so a phase declared but never written to left
  phases empty. Both reproduced against the built module before fixing.

No assertion was weakened. The withholding path is still exercised and still
returns null when the roadmap is absent.

* chore(#2790): backfill changeset pr number

* test(#2790): cover every enumerated matrix row and contain a symlink escape

Reverses a silent deferral. An earlier revision left 23 of the 78 enumerated
matrix rows unimplemented and 7 more as one-off manual checks, with a paragraph
in the artifact and the PR body describing the gap. CLAUDE.md is explicit that
such a note is not a fix and is not surfacing. The rows are implemented instead
and the manual-evidence bucket is gone: 49 test cases become 88, covering all 78.

Writing the symlink row proved a real leak: a *-PLAN.md symlinked outside
.planning/ had its content emitted into the payload, confirmed via a direct call
and the spawned CLI. readDocument now resolves target and planning root with
realpathSync and rejects an escape, returning the ordinary unreadable-document
shape. Tested both ways, because a containment check that over-rejects is its own
defect: an escaping symlink leaks nothing and degrades that plan alone, while a
legitimately relocated .planning/ symlink stays fully readable.

The three new modules are registered in the mutation COVERED registry, which had
been reporting has_work false and skipping the Stryker gate entirely. Provisional
non-binding floors so the shards run and report; raised to the measured value
before merge, since the registry forbids calibrating from a local run.

* fix(#2790): satisfy the mutation ratchet contract and scope the 1MB test

Remote runner reported 16 failures on 8c451ed. Two causes.

The COVERED registry has a paired contract the earlier commit violated: every
module needs a matching RATCHET_BASELINE entry, and minScore must be between 50
and 100 with minScore === baseline. The provisional floor of 1 was illegal on
both counts. All three modules now sit at 50 — the registry's own enforced
minimum — with matching baselines. The score cannot be measured locally: the
shard runs node --test, which this repo hard-blocks, so CI is the only source.
Floors are raised to the measured value once this PR's shards report; a shard
below 50 means the tests need strengthening, since the floor cannot go lower.

The 1MB test was measuring the test harness rather than the product. The command
handles the oversized payload correctly by spilling to a tmpfile and resolving it
back, but the resolved stdout then exceeds runGsdTools' maxBuffer and the helper
reports ENOBUFS. It now uses --pick so stdout stays one byte while the full 1MB
document is still read and parsed end to end.

* fix(#2790): wire containment across every document read this command drives

An isolated security review of the containment control found the boundary logic
sound but not comprehensively wired: two content reads reached the filesystem
without it.

An escaped phase DIRECTORY could enumerate external filenames into the file
fields and diagnostic subjects. Both enumeration sites now containment-check the
directory before reading. Worth recording that the leak was already prevented one
layer earlier than the review claimed: Dirent#isDirectory() reports false for a
directory symlink, so such a directory never becomes a phase row at all. The
guard is defense-in-depth for a direct caller and for platforms where a reparse
point reports as a directory.

A *-VERIFICATION.md symlinked outside the root leaked one frontmatter value
verbatim, because readVerificationStatus does its own read and copies an
unrecognized status into the payload's next_action. Closed from the consumer
side through that function's existing fs injection seam, so src/verification.cts
keeps its signature and its other callers are untouched.

The reviewer additionally rated a forged status: passed as an integrity bypass.
It is not: anyone able to plant the symlink can plant a real VERIFICATION.md
saying the same thing. The incremental risk is confidentiality, which is what
these fixes close.

src/plan-scan.cts is deliberately unchanged: isPlanSuperseded reads
symlink-followed content but yields only a derived boolean, no document text.

* test(#2790): give the mutation shards an in-process surface

Two Stryker shards were CANCELLED at the 15-minute cap, not failed on score.
CI log: 640 mutants instrumented, and the dry run reported 'Ran 1 tests in 20
seconds' because the shards pointed at the integration suite, where nearly every
case spawns a gsd-tools subprocess and Stryker's command runner treats the whole
test-runner invocation as a single test. 640 x 20s cannot finish in 15 minutes;
at the kill it was 27/640 with an ETA over an hour.

Every other COVERED module points at a property or unit file, and the workflow's
own paths filter lists exactly those two patterns. In-process is the intended
mutation surface; the shards were pointed at the wrong shape of test.

Adds tests/planning-inspect.unit.test.cjs — 39 cases in 10 describes that spawn
nothing and call the built modules directly. plan-document and the router need no
filesystem at all, one being a pure content-to-object parser and the other taking
an injected mock. The three shards now point here. The 91-case integration suite
is untouched and still runs in the normal test job.

* chore(#2790): ratchet mutation floors to the measured CI scores

CI run 32392791843 measured all three shards, which is the only source the
registry accepts — local runs count timeouts as kills and inflate badly.

  planning-command-router  95.65 -> floor 94
  plan-document            76.58 -> floor 75
  planning-inspect         57.03 -> floor 56

Applied the registry's own rule, floor(score) - 1, and updated RATCHET_BASELINE
to match, since the ratchet test enforces equality.

planning-inspect sits well below the file's target of 80 and is the obvious
ratchet candidate as its tests improve. planning-command-router already exceeds
the target. The placeholder comment about floors pending measurement is removed
rather than left standing as a false statement.

---------

Co-authored-by: sim <sim@local>
2026-08-20 13:42:43 -04:00

199 lines
7.9 KiB
Markdown

# Consume the planning snapshot
You are building something that needs to know where a GSD project stands —
a dashboard, a status page, a harness UI, a bot that comments on a pull request.
`planning inspect` gives you that as one JSON document, so you never have to
parse `ROADMAP.md`, `REQUIREMENTS.md`, `*-PLAN.md`, or `*-SUMMARY.md` yourself.
This guide covers the whole path from *off* to *reading a value you can trust*,
including the part most integrations get wrong: telling **"nothing to report"**
apart from **"could not look."**
## Before you start
You need `gsd-tools` on the machine, and a project directory containing
`.planning/`. Nothing has to be enabled or configured — the command is read-only
and always available.
## 1. Get a snapshot
```bash
gsd-tools query planning inspect
```
The dotted form is identical, if that reads better in your code:
```bash
gsd-tools query planning.inspect
```
The command takes no arguments. If you pass one, it fails loudly rather than
ignoring it — see [Troubleshooting](#troubleshooting).
To inspect a project other than your current directory, use the global `--cwd`
flag, which every `gsd-tools` command accepts:
```bash
gsd-tools query planning inspect --cwd /path/to/project
```
## 2. Check the schema version first
```json
{ "schema_version": 1 }
```
**Reject any version you were not written against.** Do this before you touch
any other field:
```javascript
const snapshot = JSON.parse(stdout);
if (snapshot.schema_version !== 1) {
throw new Error(`Unsupported planning snapshot schema: ${snapshot.schema_version}`);
}
```
Best-effort-parsing an unknown shape is how an integration starts silently
reporting wrong numbers after an upgrade. A hard failure is the kinder outcome.
## 3. Read a value — and check its scope
Most answers arrive alongside a `scope`. It tells you whether the value is a
real answer or a placeholder for one that could not be produced:
| `scope` | Meaning | What to render |
|---|---|---|
| `complete` | The read succeeded. The value is real — **including when it is `0` or `[]`** | The value |
| `truncated` | Part of the source could not be read | The value, marked partial |
| `unscoped` | The source exists but nothing could be located in it | "Unknown" |
| `unreadable` | The source could not be read at all | "Unknown" |
The distinction that matters: **`complete` with an empty value is a real answer.**
A milestone with zero phases genuinely has zero phases. `unreadable` with an
empty value means nobody looked. Rendering both as "0 phases" is the bug this
field exists to prevent.
```javascript
const phases = snapshot.progress.accepted_phases;
if (phases.scope !== 'complete') {
render('Progress unavailable'); // could not look
} else {
render(`${phases.completed} / ${phases.total}`); // real, even if 0 / 0
}
```
## 4. Handle a withheld percentage
`progress.accepted_phases` and `progress.completed_plans` each carry
`{completed, total, percent, scope}`. **`percent` is `null` whenever `scope` is
not `complete`.**
That is deliberate. A percentage computed from a partial phase set is a
confidently wrong number, and a consumer cannot tell it apart from a real one.
Do not substitute `0`, and do not compute your own from `completed / total` —
those counts are partial too.
```javascript
const { percent } = snapshot.progress.completed_plans;
render(percent === null ? '—' : `${percent}%`);
```
`percent: 0` under a `complete` scope is a real 0 and should be rendered.
## 5. Read the three kinds of phase evidence separately
Each entry in `phases[]` reports three independent signals. **They are not
combined into one verdict, and you should not combine them either** — they
answer different questions and can legitimately disagree.
| Field | Question it answers |
|---|---|
| `verification` | Did the verifier pass this phase? This is what `complete` is derived from |
| `roadmap_acceptance` | Is the ROADMAP checkbox ticked? |
| `uat` | Are there unresolved user-acceptance items? |
`roadmap_acceptance` carries `authoritative: false`, and it means it. A ticked
checkbox is a human annotation with no machine authority — completion comes from
disk state. If you show the checkbox, label it as an annotation, not as status.
A phase can be `complete: true` with open UAT items. That is a real state, not a
contradiction.
## 6. Handle `unknown` rather than guessing
Where evidence is absent or two sources disagree, the value is `null` or
`"unknown"` and `diagnostics[]` says why. Nothing is inferred.
The case you will hit most often is task-scoped file provenance:
| `provenance` | What it means | What to show |
|---|---|---|
| `task_scoped` | The summary attributed files to this exact task | The file list |
| `plan_scoped` | A summary exists, but only lists files for the whole plan | "Not attributed" — **not** the plan's list |
| `absent` | No summary yet | "Not started" |
`plan_scoped` is the common case and is not an error. Attributing a plan's file
list to one of its tasks would be a guess, so the snapshot declines to make it.
The plan-level list is still available at `plans[].changed_files`, where it is
accurate.
When a task's planned and changed files both exist and disagree, `agreement` is
`"conflicting"` and both lists are present, unreconciled. Show both; do not pick.
## 7. Read the diagnostics
Every non-answer above has a matching entry in `diagnostics[]`:
```json
{ "code": "requirement_unmapped", "subject": "AUTH-03", "detail": "..." }
```
`code` is a stable identifier from a frozen vocabulary — match on it, never on
`detail`, whose wording may change. Common codes:
| Code | Meaning |
|---|---|
| `planning_root_absent` | No `.planning/` directory — every section below is a non-answer |
| `roadmap_unscoped` | No milestone version could be resolved; none was invented |
| `requirements_absent` | No `REQUIREMENTS.md` |
| `requirement_unmapped` | A requirement no Traceability row maps to a phase |
| `requirement_phase_unknown` | A requirement mapped to a phase that is not on disk |
| `orphan_phase_dir` | A phase directory the current milestone does not declare |
| `task_changed_files_plan_scoped` | Task-level file attribution unavailable (see step 6) |
| `task_changed_files_conflicting` | Planned and changed files disagree |
| `percent_withheld` | A percentage was suppressed because its scope was not `complete` |
An empty `diagnostics[]` means every value in the snapshot is a real answer.
## 8. Handle a large payload
On a big project the JSON can exceed the ~50 KB console limit. `gsd-tools`
handles this for you: it writes to a temp file and resolves the reference before
writing to stdout, so you always receive JSON. If you are invoking `gsd-tools`
through a shell wrapper that captures stdout directly, no special handling is
needed.
## Troubleshooting
**`Unknown planning subcommand. Available: inspect`**
You typed a subcommand that does not exist. `inspect` is the only one.
**`planning inspect takes no arguments; got flag: --phase`**
v1 always returns the whole project. It refuses scoping arguments rather than
ignoring them — silently returning an unscoped snapshot to a caller who asked
for a scoped one would be worse. Filter the `phases[]` array on your side.
**Everything is `unknown` and `diagnostics[0].code` is `planning_root_absent`**
You are not in a GSD project directory. Use `--cwd`, or `cd` first.
**A phase you expect is missing from `phases[]`**
Check `orphan_phase_dirs[]`. `phases[]` is scoped to the phases the current
milestone's ROADMAP window declares; a directory on disk that the roadmap never
mentions is reported there instead, so that a genuinely orphaned directory
cannot masquerade as a planned phase.
## Related
- [`planning inspect` reference](../CLI-TOOLS.md#planning-inspect) — every field, with exact semantics
- [Resolve unreachable-guard findings](resolve-unreachable-guard-findings.md) — the same "nothing to report vs. could not look" distinction, one layer down