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>
This commit is contained in:
Tom Boucher
2026-08-20 13:42:43 -04:00
committed by GitHub
parent 77fa08f1e8
commit 8da2dd3ad2
23 changed files with 5413 additions and 58 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3708
---
**New `planning inspect` query emits a schema-v1 snapshot of the whole planning state** — downstream harness UIs and dashboards can now read milestone identity, active position, per-phase verification/roadmap-acceptance/UAT evidence, requirement traceability, plan and task rows, and progress fractions from one read-only JSON document instead of parsing GSD's Markdown a second time. Unknown or conflicting evidence is reported as `unknown` with a coded diagnostic rather than inferred. (#2790)

3
.gitignore vendored
View File

@@ -201,6 +201,9 @@ build/
/gsd-core/bin/lib/planning-workspace.cjs /gsd-core/bin/lib/planning-workspace.cjs
/gsd-core/bin/lib/planning-scope.cjs /gsd-core/bin/lib/planning-scope.cjs
/gsd-core/bin/lib/planning-snapshot.cjs /gsd-core/bin/lib/planning-snapshot.cjs
/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/pattern.cjs /gsd-core/bin/lib/pattern.cjs
/gsd-core/bin/lib/text-lines.cjs /gsd-core/bin/lib/text-lines.cjs
/gsd-core/bin/lib/token-scanner.cjs /gsd-core/bin/lib/token-scanner.cjs

View File

@@ -118,6 +118,12 @@ Leaf module generalizing the proven `hooks/lib/git-cmd.js` token-walk (#3129) in
### Planning Snapshot Module ### Planning Snapshot Module
Module owning the parsed projection of `.planning/` that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). `buildPlanningSnapshot(cwd) → PlanningSnapshot` is composed EXCLUSIVELY from the already-consolidated §7 owners — `getMilestoneInfo` (Roadmap Parser Module), `listMilestonePhaseDirs` (Phase Locator Module), `isPhaseComplete` (Verification Module), `scanPhasePlans` (Plan Scan Module), `stateFieldValue`/`stateCurrentPositionSlice` (STATE.md Document Module), `planningPaths` (Planning Workspace Module) — and introduces no new semantic derivation of its own. `PlanningSnapshot` exposes `milestone`/`phaseDirs`/`phases`/`currentPhaseLabel`, each a `{value, scope}` pair per the Planning Scope Module's frozen `SCOPE` enum; `phases` additionally carries a `PhaseSnapshot[]` (`dir`, `complete`, `verificationStatus`, `planCount`, `summaryCount`, `scope`). The one new piece of logic this module adds is `worstScope(...scopes) → Scope`, a pure severity-ordered combinator (`UNREADABLE` > `UNSCOPED` > `TRUNCATED` > `COMPLETE`) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed `scope` verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way `#3162`'s three inert `Current Phase` literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's `warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE)`. Guarded by `scripts/lint-planning-snapshot-bypass-drift.cjs` (ratcheted per Decision 4(e), scoped to `DIAGNOSTIC_RULE_FUNCTIONS` — currently `cmdValidateHealth` in `src/verify.cts` only, acknowledging its existing raw `.planning/` reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: `gsd-core/bin/lib/planning-snapshot.cjs` (generated from `src/planning-snapshot.cts`). Design: `.gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md`. Module owning the parsed projection of `.planning/` that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). `buildPlanningSnapshot(cwd) → PlanningSnapshot` is composed EXCLUSIVELY from the already-consolidated §7 owners — `getMilestoneInfo` (Roadmap Parser Module), `listMilestonePhaseDirs` (Phase Locator Module), `isPhaseComplete` (Verification Module), `scanPhasePlans` (Plan Scan Module), `stateFieldValue`/`stateCurrentPositionSlice` (STATE.md Document Module), `planningPaths` (Planning Workspace Module) — and introduces no new semantic derivation of its own. `PlanningSnapshot` exposes `milestone`/`phaseDirs`/`phases`/`currentPhaseLabel`, each a `{value, scope}` pair per the Planning Scope Module's frozen `SCOPE` enum; `phases` additionally carries a `PhaseSnapshot[]` (`dir`, `complete`, `verificationStatus`, `planCount`, `summaryCount`, `scope`). The one new piece of logic this module adds is `worstScope(...scopes) → Scope`, a pure severity-ordered combinator (`UNREADABLE` > `UNSCOPED` > `TRUNCATED` > `COMPLETE`) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed `scope` verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way `#3162`'s three inert `Current Phase` literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's `warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE)`. Guarded by `scripts/lint-planning-snapshot-bypass-drift.cjs` (ratcheted per Decision 4(e), scoped to `DIAGNOSTIC_RULE_FUNCTIONS` — currently `cmdValidateHealth` in `src/verify.cts` only, acknowledging its existing raw `.planning/` reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: `gsd-core/bin/lib/planning-snapshot.cjs` (generated from `src/planning-snapshot.cts`). Design: `.gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md`.
### Plan Document Module
Leaf module owning the parse of a `*-PLAN.md` document BODY: `<objective>` extraction, the `<task>` block grammar (with the legacy `## Task N` heading fallback), per-task `<files>` / `<acceptance_criteria>` / `<done>`, and the frontmatter-derived scheduling metadata (`wave`, `depends_on`, `autonomous`, `agent_hint`, `files_modified`). `parsePlanDocument(content, planPath?) → PlanDocument`; `TASK_KIND` is a frozen `{AUTO, CHECKPOINT}` enum so a `<task type="checkpoint:*">` block — which carries an entirely different element set (`<decision>`/`<what-built>`, no `<name>`/`<files>`) — is reported as its own kind rather than as a malformed auto task. Extracted from `cmdPhasePlanIndex`'s inline pass-1 loop (#2790) because two commands in two families now need it (`phase.plan-index` and `planning.inspect`); leaving it in `phase.cts` would have forced a `planning` → `phase` dependency, and copying it is the `DEFECT.GENERATIVE-FIX` shape. **NOT an ADR-3180 §7 derivation** — §6 puts the document-parsing layer (#2143) explicitly out of that epic's scope; this module answers "what does this plan document say", never "how many plans are outstanding" (`scanPhasePlans`, §7.5) or "is this phase complete" (`isPhaseComplete`, §7.4). Behaviour is byte-for-behaviour identical to the prior inline code, INCLUDING the invariant `taskCount === tasks.length === (xmlTaskCount || mdTaskCount)` and its known fence-blindness (a `## Task 1` inside a fenced block still counts) — characterised, not endorsed: changing it would silently alter `phase.plan-index`'s output for existing projects. Source of truth: `gsd-core/bin/lib/plan-document.cjs` (generated from `src/plan-document.cts`).
### 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`.
### Health Diagnostic Types Module ### 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`). 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

@@ -578,6 +578,102 @@ Pass `--json` to receive the typed IR directly (useful in scripts and test asser
--- ---
## Planning Snapshot Commands
### `planning inspect`
Emits a read-only, schema-versioned snapshot of everything `.planning/` knows,
as one JSON document. It exists so a downstream tool — a harness UI, a
mission-control view, a dashboard — can consume planning state without parsing
`ROADMAP.md` / `REQUIREMENTS.md` / `*-PLAN.md` / `*-SUMMARY.md` a second time
and drifting from gsd-core's own answers.
```bash
gsd-tools query planning inspect
gsd-tools query planning.inspect # dotted canonical form — identical output
```
**Takes no arguments.** A stray positional or an unrecognized flag is a
fail-loud usage error, not a silently-ignored one: a caller who believed
`--phase 3` was scoping the query would otherwise receive a whole-project
snapshot presented as a scoped one.
`planning inspect` writes nothing, anywhere. It is safe to run against a
project mid-workflow.
#### The schema contract
```json
{ "schema_version": 1, "...": "..." }
```
`schema_version` is the contract. **A consumer must reject any value other than
the one it was written against** rather than best-effort-parsing a shape it does
not know. Every top-level key is always present; a key is never omitted to
signal absence, because omission is itself something callers come to depend on.
| Key | What it carries |
|-----|-----------------|
| `schema_version` | Always `1` today |
| `generated_from` | Resolved `cwd` and `.planning/` root (`null` when there is no planning root) |
| `milestone` | `version`, `name`, and the `scope` of that answer |
| `active` | `phase`, `plan`, and `status` — three distinct STATE.md facts, each scoped separately |
| `phases[]` | Per phase: completion, verification, roadmap acceptance, UAT, plan and task rows |
| `orphan_phase_dirs[]` | Directories under `phases/` that the current milestone window does not declare |
| `requirements[]` | Requirement rows with mapped-phase traceability |
| `progress` | `accepted_phases` and `completed_plans`, as independent fractions |
| `diagnostics[]` | Coded reasons for every non-answer above |
#### Three kinds of evidence, never folded together
Each phase reports `verification`, `roadmap_acceptance`, and `uat` **side by
side**. They are not combined into a single verdict, because they answer
different questions and can legitimately disagree — a phase can pass
verification while UAT items remain open.
`roadmap_acceptance.checkbox` is reported with `authoritative: false`. A ticked
ROADMAP checkbox is a human annotation with no machine authority: completion is
derived from disk state (a passing `*-VERIFICATION.md`), and a stale tick never
overrides it. See [Milestone window scope](#milestone-window-scope-roadmap-analyze).
#### Unknown is a real answer; nothing is inferred
Where the evidence is absent, or where two sources disagree, the value is `null`
or `"unknown"` and a coded entry in `diagnostics[]` says why. It is never
reconciled, guessed, or filled from a plausible default.
The most common case is task-scoped file provenance. A `<task>` block declares
the files it plans to touch, but `SUMMARY.md`'s `## Files Created/Modified`
section describes the **whole plan**, not an individual task. Spreading that
plan-level list across the plan's tasks would be inference, so instead:
| `provenance` | Meaning |
|---|---|
| `task_scoped` | The summary attributed files to this specific task (via a deviation block naming `Found during: Task N`) |
| `plan_scoped` | A summary exists, but only carries a plan-level file list — this task's changed files are unknown |
| `absent` | No summary exists yet |
When a task's planned and changed file sets both exist and disagree,
`agreement` is `"conflicting"` and **both lists are emitted verbatim**.
#### Percentages are withheld rather than guessed
`progress.accepted_phases` and `progress.completed_plans` are independent
fractions, each `{completed, total, percent, scope}`. `percent` is `null`
whenever `scope` is anything other than `complete` — the same rule the roadmap
and progress surfaces follow, for the same reason. See
[A non-`COMPLETE` scope withholds the percentage entirely](#a-non-complete-scope-withholds-the-percentage-entirely-3217).
`0` is a real answer under a `complete` scope and is never withheld.
#### Large payloads
Output over ~50 KB is written to a temp file and returned as
`@file:<path>`, which `gsd-tools` resolves transparently before writing to
stdout — the same channel `init` uses. Callers see JSON either way.
---
## Template Commands ## Template Commands
Template selection and filling. Template selection and filling.

View File

@@ -652,6 +652,31 @@ node gsd-tools.cjs phase uat-passed 3 --raw # Machine-readable
--- ---
### `planning inspect`
Emit a read-only, schema-versioned JSON snapshot of the whole planning state —
milestone identity, active phase/plan/status, per-phase verification, roadmap
acceptance and UAT evidence (kept separate), requirement rows with mapped-phase
traceability, plan and task rows with planned/changed file provenance, and
independent `accepted_phases` / `completed_plans` fractions.
For downstream tools that need planning state without re-parsing GSD's Markdown.
Mutates nothing. Takes no arguments — a stray positional or unknown flag is a
fail-loud usage error rather than a silently-ignored one.
```bash
node gsd-tools.cjs query planning inspect # schema-v1 snapshot
node gsd-tools.cjs query planning.inspect # dotted canonical form, identical
node gsd-tools.cjs query planning inspect --cwd /path/to/project
```
Check `schema_version` before reading any other field, and branch on each value's
`scope` — `complete` with an empty value is a real answer, `unreadable` is not.
Full field reference: [CLI Tools](CLI-TOOLS.md#planning-inspect). Integration
walkthrough: [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md).
---
## Navigation Commands ## Navigation Commands
### `/gsd-next` ### `/gsd-next`

View File

@@ -3473,3 +3473,26 @@ See [Resolve verify-command path findings](how-to/resolve-verify-command-path-fi
**A proxy, never a drift measurement.** The count includes commits that touched nothing `STATE.md` describes, and the stamp restamps on every state write — so a low count means "something wrote STATE recently", not "STATE is accurate". Rendered with a `~`; never gate on it. **A proxy, never a drift measurement.** The count includes commits that touched nothing `STATE.md` describes, and the stamp restamps on every state write — so a low count means "something wrote STATE recently", not "STATE is accurate". Rendered with a `~`; never gate on it.
**Reference:** [Configuration](CONFIGURATION.md) · [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) · [ADR-2164](adr/2164-statusline-scope-boundary.md) **Reference:** [Configuration](CONFIGURATION.md) · [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) · [ADR-2164](adr/2164-statusline-scope-boundary.md)
### 163. Read-Only Planning Snapshot (`planning inspect`)
**Command:** `gsd-tools query planning inspect`
**Purpose:** Give downstream consumers — harness UIs, mission-control surfaces, dashboards, bots — one schema-versioned JSON document describing everything `.planning/` knows, so nothing outside gsd-core has to parse `ROADMAP.md` / `REQUIREMENTS.md` / `*-PLAN.md` / `*-SUMMARY.md` a second time. gsd-core is the single source of `.planning/` truth; a second parser is a second answer.
**Requirements:**
- REQ-INSP-01: `PLANNING_INSPECT_SCHEMA_VERSION = 1` is emitted as `schema_version`. Consumers MUST reject any other value rather than best-effort-parse an unknown shape.
- REQ-INSP-02: Read-only. The command mutates no planning state, and mutates nothing on disk, under any input.
- REQ-INSP-03: Unknown or conflicting evidence serializes as `null` / `"unknown"` with a coded entry in `diagnostics[]` — never inferred, reconciled, or defaulted. Every key is always present; a key is never omitted to signal absence.
- REQ-INSP-04: Argument errors fail loud (non-zero exit, typed `ERROR_REASON`); data gaps do not. v1 takes no arguments, and a stray positional or unknown flag is a usage error rather than a silently-ignored one.
- REQ-INSP-05: Roadmap acceptance, verification status, and UAT items are reported side by side per phase and are never folded into a single verdict. A ROADMAP checkbox carries `authoritative: false` — completion is derived from disk state.
- REQ-INSP-06: `accepted_phases` and `completed_plans` are independent fractions. `percent` is `null` whenever the scope is not `complete`, per the same rule the roadmap and progress surfaces follow.
- REQ-INSP-07: Payloads over ~50 KB use the existing `@file:` spill channel, resolved transparently before stdout.
**Why it does not simply serialize the internal snapshot.** `PlanningSnapshot` (the diagnostic-rule subject introduced by ADR-3180 §8.1) is deliberately additive and still growing — four fields at Phase 10, twenty-plus by Phase 12. Handing that shape to external consumers would freeze an internal contract by accident. `planning inspect` declares its own flat schema and maps into it, so a field added to `PlanningSnapshot` never changes what this command emits.
**Composed, never re-derived.** Milestone identity and phase enumeration arrive via `buildPlanningSnapshot`; completion from `isPhaseComplete` (disk-strict); live-plan counting from `scanPhasePlans`; the percentage arithmetic from `clampPercent`; STATE fields from `stateFieldValue`; plan bodies from the Plan Document Module; requirement IDs from `parseRequirements`; UAT items from `parseUatItems`. Markdown structure is read through the Markdown Sectionizer and Markdown Table Model seams, so the Traceability table is resolved by column name against its registered schema rather than by a position-anchored regex.
**Known limit — task-scoped file provenance.** A `<task>` declares the files it plans to touch, but `SUMMARY.md`'s `## Files Created/Modified` describes the whole plan. Spreading that list across a plan's tasks would be inference, so a task's `changed_files` is populated only where the summary attributes files to that specific task; otherwise it is `null` with `provenance: "plan_scoped"`. Closing this needs a change to the SUMMARY format, not to the reader.
**Reference:** [CLI Tools](CLI-TOOLS.md#planning-inspect) · [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md)

View File

@@ -453,8 +453,11 @@
"phase.cjs", "phase.cjs",
"phases-command-router.cjs", "phases-command-router.cjs",
"plan-dependency-graph.cjs", "plan-dependency-graph.cjs",
"plan-document.cjs",
"plan-drift-guard.cjs", "plan-drift-guard.cjs",
"plan-scan.cjs", "plan-scan.cjs",
"planning-command-router.cjs",
"planning-inspect.cjs",
"planning-scope.cjs", "planning-scope.cjs",
"planning-snapshot.cjs", "planning-snapshot.cjs",
"planning-workspace.cjs", "planning-workspace.cjs",

View File

@@ -556,7 +556,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` | | `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
| `plan-dependency-graph.cjs` | Shared halt-propagation over a plan's `depends_on` DAG — the single topological-order + halt-propagation engine used by both `phase.cjs`'s wave-grouping and `phase-locator.cjs`'s phase-location primitive, so the two can never diverge on which plans a halted plan blocks (#2830) | | `plan-dependency-graph.cjs` | Shared halt-propagation over a plan's `depends_on` DAG — the single topological-order + halt-propagation engine used by both `phase.cjs`'s wave-grouping and `phase-locator.cjs`'s phase-location primitive, so the two can never diverge on which plans a halted plan blocks (#2830) |
| `plan-document.cjs` | Canonical parser for a `*-PLAN.md` document BODY (compiled from `src/plan-document.cts`, gitignored; #2790) — `parsePlanDocument(content, planPath?)` returns `<objective>`, the `<task>` block rows (with the legacy `## Task N` heading fallback), per-task `<files>`/`<acceptance_criteria>`/`<done>`, and the frontmatter scheduling metadata (`wave`, `depends_on`, `autonomous`, `agent_hint`, `files_modified`); frozen `TASK_KIND` (`AUTO`/`CHECKPOINT`) so a `checkpoint:*` block is reported as its own kind rather than a malformed auto task. Extracted from `cmdPhasePlanIndex`'s inline loop so `phase.plan-index` and `planning.inspect` cannot drift; the invariant `taskCount === tasks.length === (xmlTaskCount \|\| mdTaskCount)` is preserved byte-for-behaviour |
| `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) | | `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) |
| `planning-command-router.cjs` | Thin CJS subcommand router for `gsd-tools planning` (compiled from `src/planning-command-router.cts`, gitignored; #2790) — one subcommand, `inspect`; v1 accepts no arguments, so a stray positional or unknown flag is a fail-loud `ERROR_REASON.USAGE` rather than a silently-ignored one |
| `planning-inspect.cjs` | Read-only schema-v1 canonical planning snapshot (compiled from `src/planning-inspect.cts`, gitignored; #2790) — `buildPlanningInspect(cwd)` / `cmdPlanningInspect`; `PLANNING_INSPECT_SCHEMA_VERSION = 1` is the wire contract consumers must reject other values of. Composed from the ADR-3180 §7 owners plus `parsePlanDocument`/`parseRequirements`/`parseUatItems`, and read through the Markdown Sectionizer + Markdown Table Model seams; deliberately declares its own flat external schema rather than serializing the still-growing `PlanningSnapshot`. Frozen `INSPECT_DIAGNOSTIC`/`TASK_STATUS`/`PROVENANCE`/`AGREEMENT` enums carry every non-answer — unknown or conflicting evidence is reported with a coded diagnostic, never inferred |
| `planning-scope.cjs` | Frozen `SCOPE` discriminator (`COMPLETE`/`TRUNCATED`/`UNSCOPED`/`UNREADABLE`) distinguishing a genuinely-empty derivation from one computed over a truncated or unscoped input, so callers can branch on the difference instead of reading a plausible zero (ADR-3180) | | `planning-scope.cjs` | Frozen `SCOPE` discriminator (`COMPLETE`/`TRUNCATED`/`UNSCOPED`/`UNREADABLE`) distinguishing a genuinely-empty derivation from one computed over a truncated or unscoped input, so callers can branch on the difference instead of reading a plausible zero (ADR-3180) |
| `planning-snapshot.cjs` | Parsed projection of `.planning/` composed exclusively from the ADR-3180 §7 owners (milestone identity, phase enumeration, phase completion, plan/summary counting, STATE.md current-phase) — exposes only scope-carrying parsed values, never raw document text, so a diagnostic rule cannot re-derive a field's location (ADR-3180 §8.1) | | `planning-snapshot.cjs` | Parsed projection of `.planning/` composed exclusively from the ADR-3180 §7 owners (milestone identity, phase enumeration, phase completion, plan/summary counting, STATE.md current-phase) — exposes only scope-carrying parsed values, never raw document text, so a diagnostic rule cannot re-derive a field's location (ADR-3180 §8.1) |
| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | | `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) |

View File

@@ -30,6 +30,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Resolve unreachable-guard findings](how-to/resolve-unreachable-guard-findings.md) — fix shell guards whose fallback arm cannot run, and tell "nothing to report" apart from "could not look" - [Resolve unreachable-guard findings](how-to/resolve-unreachable-guard-findings.md) — fix shell guards whose fallback arm cannot run, and tell "nothing to report" apart from "could not look"
- [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 - [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" - [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"
- [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) - [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)
- [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality - [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality
- [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents - [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents

View File

@@ -0,0 +1,198 @@
# 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

View File

@@ -157,6 +157,12 @@ export default tseslint.config(
'gsd-core/bin/lib/configuration.cjs', 'gsd-core/bin/lib/configuration.cjs',
'gsd-core/bin/lib/state-document.cjs', 'gsd-core/bin/lib/state-document.cjs',
'gsd-core/bin/lib/planning-snapshot.cjs', 'gsd-core/bin/lib/planning-snapshot.cjs',
// #2790: tsc-generated runtime artifacts — lint the src/*.cts sources
// (src/planning-inspect.cts, src/planning-command-router.cts,
// src/plan-document.cts), not these emitted .cjs files.
'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/pattern.cjs', 'gsd-core/bin/lib/pattern.cjs',
'gsd-core/bin/lib/text-lines.cjs', 'gsd-core/bin/lib/text-lines.cjs',
'gsd-core/bin/lib/token-scanner.cjs', 'gsd-core/bin/lib/token-scanner.cjs',

View File

@@ -98,6 +98,15 @@
* validate health [--repair] Check .planning/ integrity, optionally repair * validate health [--repair] Check .planning/ integrity, optionally repair
* validate agents Check GSD agent installation status * validate agents Check GSD agent installation status
* *
* Planning Snapshot:
* planning inspect Read-only schema-v1 canonical planning snapshot
* (milestone identity, active phase, per-phase
* verification/roadmap-acceptance/UAT evidence kept
* separate, requirement rows with mapped-phase
* traceability, plan/task rows with planned+changed
* file provenance, and independent accepted_phases /
* completed_plans fractions). Takes no arguments.
*
* Progress: * Progress:
* progress [json|table|bar] Render progress in various formats * progress [json|table|bar] Render progress in various formats
* *
@@ -297,6 +306,7 @@ const { routeVerifyCommand } = require('./lib/verify-command-router.cjs');
const { routeEvalCommand } = require('./lib/eval-command-router.cjs'); const { routeEvalCommand } = require('./lib/eval-command-router.cjs');
const evalMod = require('./lib/eval.cjs'); const evalMod = require('./lib/eval.cjs');
const { routeVerificationCommand } = require('./lib/verification-command-router.cjs'); const { routeVerificationCommand } = require('./lib/verification-command-router.cjs');
const { routePlanningCommand } = require('./lib/planning-command-router.cjs');
const verification = require('./lib/verification.cjs'); const verification = require('./lib/verification.cjs');
const { routeInitCommand } = require('./lib/init-command-router.cjs'); const { routeInitCommand } = require('./lib/init-command-router.cjs');
// Stale-bake guard (#1688): warns once when model config changed since agents // Stale-bake guard (#1688): warns once when model config changed since agents
@@ -3728,6 +3738,10 @@ const HOST_COMMAND_ROUTERS = {
'skill-manifest': routeSkillManifest, 'skill-manifest': routeSkillManifest,
'history-digest': routeHistoryDigest, 'history-digest': routeHistoryDigest,
'phases': routePhases, 'phases': routePhases,
// #2790: read-only schema-v1 planning snapshot. The router imports its own
// io/planning-inspect deps, so it needs no module injection — it receives
// { args, cwd, raw, error } and ignores the rest of the dispatch context.
'planning': routePlanningCommand,
'assumption-delta': routeAssumptionDelta, 'assumption-delta': routeAssumptionDelta,
'requirements': routeRequirements, 'requirements': routeRequirements,
'gap-analysis': routeGapAnalysis, 'gap-analysis': routeGapAnalysis,
@@ -3974,7 +3988,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <fiel
'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' + 'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, planning, profile-questionnaire, ' +
'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' + 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, inspect-dispatch-isolation, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-agent, resolve-dispatch-type, ' + 'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, inspect-dispatch-isolation, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-agent, resolve-dispatch-type, ' +
'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' + 'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +

View File

@@ -198,6 +198,67 @@ const COVERED = {
], ],
minScore: 75, // measured 77.52% (2026-06-14, issue #1187); floor = 77 - 2 minScore: 75, // measured 77.52% (2026-06-14, issue #1187); floor = 77 - 2
}, },
// planning-inspect / plan-document / planning-command-router: net-new modules
// added by #2790. Registered here so the Stryker gate stops SKIPPING them
// (previously has_work: "false" — ~1000 LOC entirely outside mutation scoring).
//
// WHY THESE SHARDS POINT AT tests/planning-inspect.unit.test.cjs, NOT
// tests/planning-inspect.test.cjs. CI evidence: two shards pointed at the
// integration file were CANCELLED at the workflow's 15-minute cap —
// "Mutation testing 4% (elapsed: ~3m, remaining: ~1h 19m) 27/640 tested".
// tests/planning-inspect.test.cjs is INTEGRATION-shaped (91 cases, most
// spawning a `gsd-tools` child process via `runGsdTools`); Stryker's command
// runner treats the whole `node --test <file>` invocation as ONE test costing
// whatever the slowest case costs (measured ~20s), and re-runs that entire
// file once per mutant — 640 mutants x 20s cannot finish in 15 minutes.
// tests/planning-inspect.unit.test.cjs is the dedicated, spawn-free,
// in-process mutation surface for exactly these three modules (measured
// locally: the whole file runs in well under a second) — the same shape
// every other entry in this registry already uses (*.property.test.cjs /
// *.unit.test.cjs). The integration suite is UNAFFECTED by this change: it
// keeps running in full in the normal (non-mutation) test job, and remains
// the source of truth for spawn-boundary/CLI-dispatch/read-only-proof
// behaviour that an in-process unit file cannot exercise.
//
// Measured CI scores (GitHub Actions run 32392791843, all three shards
// PASSED — not a local run; mutation shards run `node --test`, hard-blocked
// in this repo's local environment):
// planning-command-router 95.65% → floor 94 (already exceeds TARGET_MUTATION_SCORE (80))
// plan-document 76.58% → floor 75
// planning-inspect 57.03% → floor 56 (well below TARGET (80) — ratchet
// candidate; comfortably clears its own floor but has real room to grow.
// Raise as its tests improve, never lower it.)
//
// All three shards point at tests/planning-inspect.unit.test.cjs (in-process,
// spawn-free, ~0.3s dry run), not tests/planning-inspect.test.cjs — that is
// what made measurement possible at all. The integration file spawns a
// subprocess per case via runGsdTools; Stryker's command runner treats the
// whole `node --test <file>` invocation as one test costing whatever the
// slowest case costs (measured ~20s), and re-runs that entire file once per
// mutant, so 640 mutants x 20s could not finish inside the 15-minute shard
// cap. The integration suite is unaffected by this change: it keeps running
// in full in the normal (non-mutation) test job.
'planning-inspect': {
cjs: 'gsd-core/bin/lib/planning-inspect.cjs',
tests: [
'tests/planning-inspect.unit.test.cjs',
],
minScore: 56,
},
'plan-document': {
cjs: 'gsd-core/bin/lib/plan-document.cjs',
tests: [
'tests/planning-inspect.unit.test.cjs',
],
minScore: 75,
},
'planning-command-router': {
cjs: 'gsd-core/bin/lib/planning-command-router.cjs',
tests: [
'tests/planning-inspect.unit.test.cjs',
],
minScore: 94,
},
}; };
// ── Files that, when changed, invalidate ALL modules ───────────────────────── // ── Files that, when changed, invalidate ALL modules ─────────────────────────

View File

@@ -92,6 +92,17 @@ function parseRequirements(reqMd: unknown): ReqItem[] {
// The **ID** is extracted from the bullet text caller-side — the seam provides // The **ID** is extracted from the bullet text caller-side — the seam provides
// the raw text; we parse the bold-ID prefix from it here. // the raw text; we parse the bold-ID prefix from it here.
const boldIdRe = new RegExp(`^\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`); const boldIdRe = new RegExp(`^\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`);
// The shipped template (gsd-core/templates/requirements.md) writes
// `- [ ] **AUTH-01**: User can sign up` — a single separator delimiter
// between the bold ID and the description. Strip AT MOST ONE leading
// delimiter (plus its surrounding whitespace) before the final `.trim()`,
// mirroring roadmap-parser.cts's `stripLeadingDelimiter` delimiter set
// (em dash, en dash, colon, hyphen) — that helper is not exported, and its
// own `+`-quantified strip removes an entire delimiter RUN, which would
// also eat a second, meaningful marker (`**X-01**: -- weird` must keep the
// `--`), so the set is mirrored here with a single-occurrence match instead
// of reused verbatim.
const ONE_LEADING_DELIMITER_RE = /^\s*[—–:-]\s*/;
for (const bullet of iterateBullets(reqMd)) { for (const bullet of iterateBullets(reqMd)) {
if (bullet.marker !== 'checkbox-unchecked' && bullet.marker !== 'checkbox-checked') continue; if (bullet.marker !== 'checkbox-unchecked' && bullet.marker !== 'checkbox-checked') continue;
const m = boldIdRe.exec(bullet.text); const m = boldIdRe.exec(bullet.text);
@@ -100,7 +111,8 @@ function parseRequirements(reqMd: unknown): ReqItem[] {
if (!idRe.test(id)) continue; if (!idRe.test(id)) continue;
if (!seen.has(id)) { if (!seen.has(id)) {
seen.add(id); seen.add(id);
out.push({ id, text: (m[2] || '').trim() }); const rawText = m[2] || '';
out.push({ id, text: rawText.replace(ONE_LEADING_DELIMITER_RE, '').trim() });
} }
} }

View File

@@ -88,6 +88,9 @@ const {
} = planningWorkspace; } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
import milestoneLockMod = require('./milestone-lock.cjs'); import milestoneLockMod = require('./milestone-lock.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planDocumentMod = require('./plan-document.cjs');
const { parsePlanDocument, planIdFromFile } = planDocumentMod;
const { extractFrontmatter } = frontmatterMod; const { extractFrontmatter } = frontmatterMod;
const { const {
readModifyWriteStateMd, readModifyWriteStateMd,
@@ -583,11 +586,6 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void {
output(notFound, raw, ''); output(notFound, raw, '');
} }
function extractObjective(content: string): string | null {
const m = content.match(/<objective>\s*\n?\s*(.+)/);
return m ? m[1].trim() : null;
}
interface RawPlan { interface RawPlan {
id: string; id: string;
declaredWave: number | null; declaredWave: number | null;
@@ -800,51 +798,14 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
const rawPlans: RawPlan[] = []; const rawPlans: RawPlan[] = [];
for (const planFile of planFiles) { for (const planFile of planFiles) {
const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', ''); const planId = planIdFromFile(planFile);
const planPath = path.join(phaseDir, planFile); const planPath = path.join(phaseDir, planFile);
const content = fs.readFileSync(planPath, 'utf-8'); const content = fs.readFileSync(planPath, 'utf-8');
// Pass planPath so a truncated PLAN.md names the file in the #1882 diagnostic. // #2790: plan-body parsing is owned by the shared Plan Document Module, so
const fm = extractFrontmatter(content, planPath); // this command and the read-only `planning.inspect` query cannot drift on
// what a plan document says. planPath is still passed so a truncated
const xmlTasks = content.match(/<task[\s>]/gi) || []; // PLAN.md names the file in the #1882 diagnostic.
const mdTasks = content.match(/##\s*Task\s*\d+/gi) || []; const planDoc = parsePlanDocument(content, planPath);
const taskCount = xmlTasks.length || mdTasks.length;
const parsedWave = parseInt(fm['wave'] as string, 10);
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
let dependsOn: string[] = [];
const fmDeps = fm['depends_on'];
if (Array.isArray(fmDeps)) {
dependsOn = fmDeps.map(String);
} else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
dependsOn = [fmDeps];
}
let autonomous = true;
if (fm['autonomous'] !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
}
let filesModified: string[] = [];
const fmFiles = fm['files_modified'] || fm['files-modified'];
if (fmFiles) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
}
// #1689: optional per-plan specialist executor hint. Read verbatim here; the
// orchestrator resolves it against the active runtime's agent dir at dispatch
// time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to
// gsd-executor when the field is unset or the named agent does not resolve.
let agentHint: string | null = null;
const fmAgentHint = fm['agent_hint'];
if (fmAgentHint !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
const hintStr = String(fmAgentHint).trim();
agentHint = hintStr !== '' ? hintStr : null;
}
const hasSummary = !unsummarizedPlanFiles.has(planFile); const hasSummary = !unsummarizedPlanFiles.has(planFile);
@@ -860,13 +821,13 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
rawPlans.push({ rawPlans.push({
id: planId, id: planId,
declaredWave, declaredWave: planDoc.declaredWave,
dependsOn, dependsOn: planDoc.dependsOn,
autonomous, autonomous: planDoc.autonomous,
objective: extractObjective(content) || (fm['objective'] as string | null) || null, objective: planDoc.objective,
filesModified, filesModified: planDoc.filesModified,
agentHint, agentHint: planDoc.agentHint,
taskCount, taskCount: planDoc.taskCount,
hasSummary, hasSummary,
halted, halted,
}); });

322
src/plan-document.cts Normal file
View File

@@ -0,0 +1,322 @@
/**
* Plan Document Module — the single parser for a `*-PLAN.md` document BODY.
*
* Owns: objective extraction, the task-block grammar (`<task>` elements, with
* the legacy `## Task N` heading fallback), planned-file extraction, and the
* frontmatter-derived scheduling metadata (`wave`, `depends_on`, `autonomous`,
* `agent_hint`, `files_modified`).
*
* WHY THIS IS A LEAF MODULE. This logic was written inline inside
* `cmdPhasePlanIndex` (`src/phase.cts`). Two commands in two different families
* now need it — `phase.plan-index` and `planning.inspect` (#2790) — so leaving
* it in `phase.cts` would force `planning` to depend on `phase`, and copying it
* would be the *Generative Fix Divergence* class `CLAUDE.md` names. A leaf owned
* by neither family is the seam that matches the actual usage (Conway's Law).
*
* NOT an ADR-3180 §7 derivation. §6 puts the document-parsing layer explicitly
* out of that epic's scope (#2143); this module answers "what does this plan
* document say", never "how many plans are outstanding" (that is
* `scanPhasePlans`, §7.5) or "is this phase complete" (`isPhaseComplete`, §7.4).
*
* BEHAVIOUR IS PRESERVED BYTE-FOR-BEHAVIOUR from the prior inline code. In
* particular `tasks.length` is exactly the legacy `taskCount`
* (`xmlTasks.length || mdTasks.length`), including its known fence-blindness —
* a `## Task 1` inside a fenced code block still counts, exactly as it does
* today. That is a characterised limit, not an endorsement: changing it would
* silently change `phase.plan-index`'s output for existing projects, which is a
* Hyrum's-Law break that belongs in its own issue rather than riding along on a
* read-only query addition.
*
* ADR-457 build-at-publish: source in src/plan-document.cts, compiled to
* gsd-core/bin/lib/plan-document.cjs (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatterMod = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatterMod;
// ─── Frozen vocabularies ──────────────────────────────────────────────────────
/**
* How a task row was expressed in the document. `auto` is the ordinary
* executable task; `checkpoint` is a `<task type="checkpoint:*">` block, which
* carries an entirely different element set (`<decision>`/`<what-built>`, no
* `<name>`/`<files>`/`<acceptance_criteria>`). Distinguishing them is what
* stops a checkpoint from being reported as a malformed auto task.
*/
const TASK_KIND = Object.freeze({
AUTO: 'auto',
CHECKPOINT: 'checkpoint',
});
type TaskKind = (typeof TASK_KIND)[keyof typeof TASK_KIND];
// ─── Shapes ───────────────────────────────────────────────────────────────────
interface PlanTask {
/** 1-based position in document order. */
index: number;
kind: TaskKind;
/** The verbatim `type` attribute when present (e.g. `auto`, `checkpoint:decision`). */
type: string | null;
/** `<name>` text, or null — always null for a checkpoint block. */
name: string | null;
/** `<files>` split on commas, trimmed, empties dropped. Never null; `[]` means "none declared". */
plannedFiles: string[];
/** `<acceptance_criteria>` bullet lines, in document order. */
acceptanceCriteria: string[];
/** `<done>` text, or null. */
done: string | null;
}
interface PlanDocument {
/** `<objective>` body, else frontmatter `objective`, else null. */
objective: string | null;
/** Frontmatter `wave` as an integer, or null when absent/unparseable. */
declaredWave: number | null;
dependsOn: string[];
autonomous: boolean;
agentHint: string | null;
/** Frontmatter `files_modified` / `files-modified`, normalised to an array. */
filesModified: string[];
tasks: PlanTask[];
/**
* Legacy count. Invariant: `taskCount === tasks.length`, always. Exposed as
* its own field so the invariant is assertable rather than assumed.
*/
taskCount: number;
}
// ─── Task grammar ─────────────────────────────────────────────────────────────
// The legacy counting rule, preserved verbatim from cmdPhasePlanIndex. `g` is
// required (we count every occurrence) and these are rebuilt per call rather
// than hoisted to module scope: a global regex carries mutable `lastIndex`
// state, and a shared instance is a cross-call contamination bug.
function xmlTaskOpenings(content: string): RegExpMatchArray[] {
return [...content.matchAll(/<task(?=[\s>])[^>]*>/gi)];
}
function markdownTaskHeadings(content: string): RegExpMatchArray[] {
return [...content.matchAll(/##\s*Task\s*\d+[^\n]*/gi)];
}
/** Extract the value of one attribute from a `<task ...>` opening tag. */
function tagAttribute(openTag: string, attr: string): string | null {
const re = new RegExp(`\\b${attr}\\s*=\\s*"([^"]*)"|\\b${attr}\\s*=\\s*'([^']*)'`, 'i');
const m = re.exec(openTag);
if (!m) return null;
const value = (m[1] ?? m[2] ?? '').trim();
return value.length > 0 ? value : null;
}
/**
* Body of the first `<tag>…</tag>` inside `block`, or null. Non-greedy and
* case-insensitive; a tag that is opened but never closed yields null rather
* than swallowing the rest of the document.
*/
function elementBody(block: string, tag: string): string | null {
const re = new RegExp(`<${tag}\\s*>([\\s\\S]*?)</${tag}\\s*>`, 'i');
const m = re.exec(block);
return m ? m[1] : null;
}
/**
* Split a `<files>` body into paths. Comma-separated per the shipped
* `templates/phase-prompt.md` grammar; newline-separated forms are tolerated
* too (Postel — liberal in what we accept), and the caller records nothing
* special for them because a path list is a path list either way.
*/
function splitFileList(body: string | null): string[] {
if (body === null) return [];
return body
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
/** `<acceptance_criteria>` carries `- ` bullets, one criterion per line. */
function splitCriteria(body: string | null): string[] {
if (body === null) return [];
return body
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => line.replace(/^[-*]\s*/, ''))
.filter((line) => line.length > 0);
}
function collapseWhitespace(value: string | null): string | null {
if (value === null) return null;
const trimmed = value.replace(/\s+/g, ' ').trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Parse the `<task>` blocks. Each opening tag found by `xmlTaskOpenings` yields
* exactly one row — the block runs from that tag to its `</task>`, or to the
* next opening tag, or to end-of-document. Bounding on the NEXT OPENING rather
* than only on `</task>` is what keeps an unclosed block from consuming its
* siblings, so the row count still equals the opening count.
*/
function parseXmlTasks(content: string): PlanTask[] {
const openings = xmlTaskOpenings(content);
return openings.map((match, i) => {
const start = match.index ?? 0;
const openTag = match[0];
const nextStart = i + 1 < openings.length ? (openings[i + 1].index ?? content.length) : content.length;
const window = content.slice(start, nextStart);
const closeIdx = window.search(/<\/task\s*>/i);
const block = closeIdx === -1 ? window : window.slice(0, closeIdx);
const type = tagAttribute(openTag, 'type');
const kind: TaskKind = type !== null && type.toLowerCase().startsWith('checkpoint')
? TASK_KIND.CHECKPOINT
: TASK_KIND.AUTO;
// A checkpoint block has no <name>/<files>/<acceptance_criteria>/<done> in
// the shipped grammar. Reading them anyway would be harmless but dishonest:
// the caller must be able to tell "this element is absent because this kind
// of task has no such element" from "this element is missing and should not
// be".
if (kind === TASK_KIND.CHECKPOINT) {
return {
index: i + 1,
kind,
type,
name: null,
plannedFiles: [],
acceptanceCriteria: [],
done: null,
};
}
return {
index: i + 1,
kind,
type,
name: collapseWhitespace(elementBody(block, 'name')),
plannedFiles: splitFileList(elementBody(block, 'files')),
acceptanceCriteria: splitCriteria(elementBody(block, 'acceptance_criteria')),
done: collapseWhitespace(elementBody(block, 'done')),
};
});
}
/**
* Legacy fallback: `## Task N` headings, used ONLY when the document carries no
* `<task>` blocks at all. Deliberately fence-blind, matching the counting rule
* `cmdPhasePlanIndex` has always used — see this module's header comment.
*/
function parseMarkdownTasks(content: string): PlanTask[] {
return markdownTaskHeadings(content).map((match, i) => ({
index: i + 1,
kind: TASK_KIND.AUTO,
type: null,
name: collapseWhitespace(match[0].replace(/^##\s*/, '')),
plannedFiles: [],
acceptanceCriteria: [],
done: null,
}));
}
// ─── Objective ────────────────────────────────────────────────────────────────
/**
* Preserved verbatim from `cmdPhasePlanIndex`: the first line following an
* `<objective>` tag. Deliberately NOT widened to the full element body — that
* would change `phase.plan-index`'s existing output for any multi-line
* objective.
*/
function extractObjective(content: string): string | null {
const m = content.match(/<objective>\s*\n?\s*(.+)/);
return m ? m[1].trim() : null;
}
// ─── Entry point ──────────────────────────────────────────────────────────────
/**
* The plan id for a plan FILE ENTRY, exactly as `scanPhasePlans` stores it
* (root entries bare, nested entries `plans/`-prefixed).
*
* This is the established derivation from `cmdPhasePlanIndex`, moved here
* VERBATIM (#2790) so `phase.plan-index` and `planning.inspect` cannot report
* different ids for the same plan — a consumer correlating the two surfaces
* needs them to join. Deliberately NOT "improved": it is a display/lookup key
* with existing callers, and changing what it returns would be a Hyrum's-Law
* break on `phase-plan-index`.
*/
function planIdFromFile(planFile: string): string {
return planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
}
/**
* Parse one plan document.
*
* @param content Raw `*-PLAN.md` text.
* @param planPath Optional path, used only to name the file in `extractFrontmatter`'s
* truncated-frontmatter diagnostic (#1882). Callers that do not have
* one omit it — this default IS the shape production uses from the
* read-only query path.
*/
function parsePlanDocument(content: string, planPath = ''): PlanDocument {
const fm = extractFrontmatter(content, planPath);
const xmlTasks = parseXmlTasks(content);
const tasks = xmlTasks.length > 0 ? xmlTasks : parseMarkdownTasks(content);
const parsedWave = parseInt(fm['wave'] as string, 10);
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
let dependsOn: string[] = [];
const fmDeps = fm['depends_on'];
if (Array.isArray(fmDeps)) {
dependsOn = fmDeps.map(String);
} else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
dependsOn = [fmDeps];
}
let autonomous = true;
if (fm['autonomous'] !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
}
let filesModified: string[] = [];
const fmFiles = fm['files_modified'] || fm['files-modified'];
if (fmFiles) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
}
let agentHint: string | null = null;
const fmAgentHint = fm['agent_hint'];
if (fmAgentHint !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
const hintStr = String(fmAgentHint).trim();
agentHint = hintStr !== '' ? hintStr : null;
}
return {
objective: extractObjective(content) || (fm['objective'] as string | null) || null,
declaredWave,
dependsOn,
autonomous,
agentHint,
filesModified,
tasks,
taskCount: tasks.length,
};
}
const planDocument = { TASK_KIND, parsePlanDocument, planIdFromFile };
// Required to merge the compile-time-only types onto the `export =` runtime
// value; there is no ES-module-syntax way to export a type alongside a CJS
// `export =`.
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace planDocument {
export { PlanDocument, PlanTask, TaskKind };
}
export = planDocument;

View File

@@ -0,0 +1,86 @@
/**
* Planning command router — CLI subcommand dispatcher for `gsd-tools planning`.
*
* Routes `planning inspect` (#2790) to `planningInspect.cmdPlanningInspect`.
* Both the spaced form (`query planning inspect`) and the dotted canonical form
* (`query planning.inspect`) reach here identically: `gsd-tools` splits a dotted
* command on its FIRST dot before dispatch, so the router never sees the dot.
*
* v1 accepts NO arguments beyond the subcommand. `--raw`, `--cwd`, `--pick`,
* `--default` and `--json-errors` are global and are spliced out of argv by
* `gsd-tools`' own `main()` before any router runs, so anything still present at
* `args[2]` or beyond is genuinely unrecognised and is a fail-loud USAGE error.
* That strictness is deliberate: silently ignoring an argument a caller believed
* was scoping the query would return a full-project snapshot presented as a
* scoped one — a confidently wrong answer.
*
* Router signature `{ args, cwd, raw, error }` — identical to the other host
* routers. Test seam: pass `_planningInspect` to inject a recording mock; the
* `_` prefix follows this repo's established seam convention.
*
* ADR-457 build-at-publish: source in src/planning-command-router.cts, compiled
* to gsd-core/bin/lib/planning-command-router.cjs (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningInspect = require('./planning-inspect.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
const { ERROR_REASON } = io;
// ─── Types ────────────────────────────────────────────────────────────────────
interface PlanningInspectModule {
cmdPlanningInspect(cwd: string, raw: boolean): void;
}
interface RoutePlanningCommandOptions {
args: string[];
cwd: string;
raw: boolean;
error: (message: string, reason?: string) => void;
/** Test seam: inject a mock planning-inspect module. Defaults to the real one. */
_planningInspect?: PlanningInspectModule;
}
// ─── Implementation ───────────────────────────────────────────────────────────
const PLANNING_SUBCOMMANDS = ['inspect'];
function routePlanningCommand({ args, cwd, raw, error, _planningInspect }: RoutePlanningCommandOptions): void {
const mod: PlanningInspectModule = _planningInspect ?? planningInspect;
routeCjsCommandFamily({
args,
subcommands: PLANNING_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand: string, available: string[]) =>
`Unknown planning subcommand. Available: ${available.join(', ')}`,
handlers: {
inspect: () => {
const extra = args.slice(2);
if (extra.length > 0) {
const offender = extra[0];
const shape = offender.startsWith('-') ? 'flag' : 'positional argument';
error(
`planning inspect takes no arguments; got ${shape}: ${offender}. ` +
'Usage: gsd-tools query planning inspect',
ERROR_REASON.USAGE,
);
return;
}
mod.cmdPlanningInspect(cwd, raw);
},
},
});
}
export = {
routePlanningCommand,
PLANNING_SUBCOMMANDS,
};

1286
src/planning-inspect.cts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -81,6 +81,19 @@ interface CurrentTest {
// ─── cmdAuditUat ───────────────────────────────────────────────────────────── // ─── cmdAuditUat ─────────────────────────────────────────────────────────────
/**
* Select the UAT documents belonging to ONE phase directory.
*
* Extracted (#2790) so `cmdAuditUat` and the read-only `planning.inspect` query
* cannot drift on which files count as this phase's UAT. `scopeToPhase` has no
* unfiltered fallback on purpose: a phase whose own UAT file is genuinely absent
* scopes to empty and contributes nothing, rather than picking up a stray
* cross-phase file (#3511).
*/
function selectPhaseUatFiles(files: string[], phaseDirName: string): string[] {
return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName);
}
function cmdAuditUat(cwd: string, raw: boolean): void { function cmdAuditUat(cwd: string, raw: boolean): void {
const phasesDir = path.join(planningDir(cwd), 'phases'); const phasesDir = path.join(planningDir(cwd), 'phases');
const hasActivePhases = fs.existsSync(phasesDir); const hasActivePhases = fs.existsSync(phasesDir);
@@ -139,7 +152,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
// under this phase's audit-uat entry. A phase whose own UAT file is // under this phase's audit-uat entry. A phase whose own UAT file is
// genuinely absent scopes to empty and contributes nothing — correct, and // genuinely absent scopes to empty and contributes nothing — correct, and
// the reason scopeToPhase has no unfiltered fallback. // the reason scopeToPhase has no unfiltered fallback.
for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), dir)) { for (const file of selectPhaseUatFiles(files, dir)) {
const uatFilePath = path.join(phaseDir, file); const uatFilePath = path.join(phaseDir, file);
const content = fs.readFileSync(uatFilePath, 'utf-8'); const content = fs.readFileSync(uatFilePath, 'utf-8');
const items = parseUatItems(content); const items = parseUatItems(content);
@@ -1657,6 +1670,8 @@ export = {
cmdAuditUat, cmdAuditUat,
cmdRenderCheckpoint, cmdRenderCheckpoint,
parseCurrentTest, parseCurrentTest,
parseUatItems,
selectPhaseUatFiles,
buildCheckpoint, buildCheckpoint,
CHECKPOINT_FRAMES, CHECKPOINT_FRAMES,
CHECKPOINT_LANGUAGE_ALIASES, CHECKPOINT_LANGUAGE_ALIASES,

View File

@@ -199,6 +199,9 @@ const RATCHET_BASELINE = {
'config-schema': 52, // CI 54.55% 2026-06-14; was 68 (timeout-inflated local) 'config-schema': 52, // CI 54.55% 2026-06-14; was 68 (timeout-inflated local)
'active-workstream-store': 80, 'active-workstream-store': 80,
'core-utils': 75, 'core-utils': 75,
'planning-inspect': 56, // CI run 32392791843: 57.03% (unit shard); ratchet candidate vs TARGET 80
'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
}; };
describe('mutation-matrix ratchet: floor equality enforcement', () => { describe('mutation-matrix ratchet: floor equality enforcement', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,778 @@
'use strict';
/**
* FAST, IN-PROCESS mutation-testing surface for `planning-inspect.cjs`,
* `plan-document.cjs`, and `planning-command-router.cjs` (#2790).
*
* Root cause this file exists to fix: `tests/planning-inspect.test.cjs` is
* INTEGRATION-shaped — it spawns a `gsd-tools` child process per case via
* `runGsdTools`. Stryker's command runner treats a `node --test <file>`
* invocation as ONE test costing whatever the slowest case costs (measured in
* CI: ~20s), and re-runs that whole file once per mutant. 640 mutants x 20s
* cannot finish inside a 15-minute shard cap — CI evidence: two shards were
* CANCELLED at 4% (27/640) after ~3 elapsed minutes. This file is the
* dedicated, spawn-free mutation surface `scripts/mutation-matrix.cjs`
* repoints those three modules' shards at; the integration suite keeps
* running unmodified in the normal (non-mutation) test job.
*
* NEVER spawn a child process here — no `runGsdTools`, `spawnSync`,
* `execFileSync`, or CLI invocation of any kind. Every case below requires
* the BUILT `.cjs` artifacts directly and calls their exports in-process.
* `plan-document.cjs` needs no filesystem at all (pure `(content) -> object`
* parser); `planning-command-router.cjs` is driven with a recording mock and
* needs no filesystem; `planning-inspect.cjs` needs small `.planning/`
* fixtures on disk (cheap disk I/O, not the cost this file exists to avoid)
* under `os.tmpdir()`.
*
* Every fixture shape and every asserted value below was verified by
* requiring the built libs directly and inspecting the real returned object
* — never guessed from reading the source alone (CLAUDE.md "verify
* assertions by executing, not retyping").
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const {
parsePlanDocument,
planIdFromFile,
TASK_KIND,
} = require('../gsd-core/bin/lib/plan-document.cjs');
const {
routePlanningCommand,
PLANNING_SUBCOMMANDS,
} = require('../gsd-core/bin/lib/planning-command-router.cjs');
const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs');
const {
buildPlanningInspect,
INSPECT_DIAGNOSTIC,
TASK_STATUS,
PROVENANCE,
AGREEMENT,
} = planningInspectLib;
// ─── Shared fs fixture helpers (planning-inspect only) ────────────────────────
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 mkCwd() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'planning-inspect-unit-'));
}
function frontmatterDoc(fmLines, bodyLines) {
return ['---', ...fmLines, '---', '', ...bodyLines].join('\n');
}
function phaseDirOf(cwd, token) {
return path.join(cwd, '.planning', 'phases', token);
}
function diagnosticCodes(payload) {
return payload.diagnostics.map((d) => d.code);
}
// ═══════════════════════════════════════════════════════════════════════════
// plan-document.cjs — pure, in-memory parser
// ═══════════════════════════════════════════════════════════════════════════
describe('plan-document — objective extraction', () => {
test('extracts the first line after an <objective> tag', () => {
const parsed = parsePlanDocument(['<objective>', 'Ship the thing', '</objective>'].join('\n'));
assert.strictEqual(parsed.objective, 'Ship the thing');
});
test('falls back to frontmatter objective when no <objective> tag is present', () => {
const parsed = parsePlanDocument(frontmatterDoc(['objective: From frontmatter'], ['no objective tag here']));
assert.strictEqual(parsed.objective, 'From frontmatter');
});
test('is null when neither the tag nor frontmatter carries an objective', () => {
const parsed = parsePlanDocument('no objective anywhere');
assert.strictEqual(parsed.objective, null);
});
test('prefers the <objective> tag over frontmatter when both are present', () => {
const parsed = parsePlanDocument(frontmatterDoc(['objective: From frontmatter'], ['<objective>', 'From tag', '</objective>']));
assert.strictEqual(parsed.objective, 'From tag');
});
});
describe('plan-document — task grammar', () => {
test('parses one <task> block with name/files/acceptance/done', () => {
const parsed = parsePlanDocument([
'<tasks>',
'<task type="auto">',
' <name> Task One </name>',
' <files>a.ts, b.ts</files>',
' <acceptance_criteria>',
'- criterion one',
'* criterion two',
' </acceptance_criteria>',
' <done> All done </done>',
'</task>',
'</tasks>',
].join('\n'));
assert.strictEqual(parsed.tasks.length, 1);
const [task] = parsed.tasks;
assert.strictEqual(task.index, 1);
assert.strictEqual(task.kind, TASK_KIND.AUTO);
assert.strictEqual(task.type, 'auto');
assert.strictEqual(task.name, 'Task One');
assert.deepStrictEqual(task.plannedFiles, ['a.ts', 'b.ts']);
assert.deepStrictEqual(task.acceptanceCriteria, ['criterion one', 'criterion two']);
assert.strictEqual(task.done, 'All done');
assert.strictEqual(parsed.taskCount, parsed.tasks.length);
});
test('splits <files> on newlines as well as commas', () => {
const parsed = parsePlanDocument([
'<task type="auto">',
' <files>',
'a.ts',
'b.ts',
' </files>',
'</task>',
].join('\n'));
assert.deepStrictEqual(parsed.tasks[0].plannedFiles, ['a.ts', 'b.ts']);
});
test('falls back to ## Task N headings when no <task> blocks exist', () => {
const parsed = parsePlanDocument([
'## Task 1: Do the thing',
'some body text',
'## Task 2: Do another thing',
].join('\n'));
assert.strictEqual(parsed.tasks.length, 2);
assert.strictEqual(parsed.taskCount, 2);
assert.strictEqual(parsed.tasks[0].name, 'Task 1: Do the thing');
assert.strictEqual(parsed.tasks[0].type, null);
assert.deepStrictEqual(parsed.tasks[0].plannedFiles, []);
assert.strictEqual(parsed.tasks[1].index, 2);
assert.strictEqual(parsed.tasks[1].name, 'Task 2: Do another thing');
});
test('prefers <task> blocks over ## Task N headings when both are present', () => {
const parsed = parsePlanDocument([
'## Task 1: Legacy heading',
'<task type="auto">',
' <name>Real task</name>',
'</task>',
].join('\n'));
assert.strictEqual(parsed.tasks.length, 1);
assert.strictEqual(parsed.tasks[0].name, 'Real task');
});
test('a checkpoint task carries no name/files/acceptance/done, even if present in the tag', () => {
const parsed = parsePlanDocument([
'<task type="checkpoint:manual">',
' <decision>Ship it?</decision>',
' <name>Should be ignored</name>',
'</task>',
].join('\n'));
const [task] = parsed.tasks;
assert.strictEqual(task.kind, TASK_KIND.CHECKPOINT);
assert.strictEqual(task.type, 'checkpoint:manual');
assert.strictEqual(task.name, null);
assert.deepStrictEqual(task.plannedFiles, []);
assert.deepStrictEqual(task.acceptanceCriteria, []);
assert.strictEqual(task.done, null);
});
test('checkpoint type detection is case-insensitive and prefix-only', () => {
const parsed = parsePlanDocument('<task type="CHECKPOINT:Manual"></task>');
assert.strictEqual(parsed.tasks[0].kind, TASK_KIND.CHECKPOINT);
});
test('an unclosed <task> block is bounded by the next opening tag, never swallowing siblings', () => {
const parsed = parsePlanDocument([
'<task type="auto">',
' <name>First (unclosed)</name>',
'<task type="auto">',
' <name>Second</name>',
'</task>',
].join('\n'));
assert.strictEqual(parsed.tasks.length, 2);
assert.strictEqual(parsed.taskCount, 2);
assert.strictEqual(parsed.tasks[0].name, 'First (unclosed)');
assert.strictEqual(parsed.tasks[1].name, 'Second');
});
test('an unclosed final <task> block runs to end of document', () => {
const parsed = parsePlanDocument(['<task type="auto">', ' <name>Only task</name>'].join('\n'));
assert.strictEqual(parsed.tasks.length, 1);
assert.strictEqual(parsed.tasks[0].name, 'Only task');
});
test('taskCount always equals tasks.length', () => {
const noTasks = parsePlanDocument('no tasks here at all');
assert.strictEqual(noTasks.taskCount, 0);
assert.deepStrictEqual(noTasks.tasks, []);
});
});
describe('plan-document — frontmatter scheduling metadata', () => {
test('invalid wave, string depends_on, autonomous false, agent_hint set, scalar files_modified', () => {
const parsed = parsePlanDocument(frontmatterDoc([
'wave: not-a-number',
'depends_on: 1-01-PLAN.md',
'autonomous: false',
'agent_hint: backend-specialist',
'files_modified: src/single.ts',
], ['body']));
assert.strictEqual(parsed.declaredWave, null);
assert.deepStrictEqual(parsed.dependsOn, ['1-01-PLAN.md']);
assert.strictEqual(parsed.autonomous, false);
assert.strictEqual(parsed.agentHint, 'backend-specialist');
assert.deepStrictEqual(parsed.filesModified, ['src/single.ts']);
});
test('valid wave, array depends_on, empty agent_hint, files-modified (hyphen) array', () => {
const parsed = parsePlanDocument(frontmatterDoc([
'wave: 3',
'depends_on: [1-01-PLAN.md, 1-02-PLAN.md]',
'agent_hint: ""',
'files-modified: [a.ts, b.ts]',
], ['body']));
assert.strictEqual(parsed.declaredWave, 3);
assert.deepStrictEqual(parsed.dependsOn, ['1-01-PLAN.md', '1-02-PLAN.md']);
assert.strictEqual(parsed.agentHint, null);
assert.deepStrictEqual(parsed.filesModified, ['a.ts', 'b.ts']);
});
test('no frontmatter at all defaults wave/dependsOn/agentHint/filesModified and autonomous true', () => {
const parsed = parsePlanDocument('plain body, no frontmatter');
assert.strictEqual(parsed.declaredWave, null);
assert.deepStrictEqual(parsed.dependsOn, []);
assert.strictEqual(parsed.autonomous, true);
assert.strictEqual(parsed.agentHint, null);
assert.deepStrictEqual(parsed.filesModified, []);
});
test('empty depends_on string is dropped, not turned into a single blank entry', () => {
const parsed = parsePlanDocument(frontmatterDoc(['depends_on: ""'], ['body']));
assert.deepStrictEqual(parsed.dependsOn, []);
});
test('autonomous absent defaults to true', () => {
const parsed = parsePlanDocument(frontmatterDoc(['wave: 1'], ['body']));
assert.strictEqual(parsed.autonomous, true);
});
});
describe('plan-document — planIdFromFile / TASK_KIND', () => {
test('strips the -PLAN.md suffix from a root-form plan file', () => {
assert.strictEqual(planIdFromFile('1-01-PLAN.md'), '1-01');
});
test('strips a bare PLAN.md to an empty id', () => {
assert.strictEqual(planIdFromFile('PLAN.md'), '');
});
test('a nested numbered plan file (plans/PLAN-01-foo.md) is left unchanged', () => {
// Neither the `-PLAN.md` nor bare `PLAN.md` suffix matches this shape —
// characterised, byte-for-behaviour-preserved limit (see module doc).
assert.strictEqual(planIdFromFile('plans/PLAN-01-foo.md'), 'plans/PLAN-01-foo.md');
});
test('a nested bare plan file (plans/PLAN.md) strips to its directory prefix', () => {
assert.strictEqual(planIdFromFile('plans/PLAN.md'), 'plans/');
});
test('TASK_KIND is the frozen two-member vocabulary', () => {
assert.deepStrictEqual(TASK_KIND, { AUTO: 'auto', CHECKPOINT: 'checkpoint' });
assert.ok(Object.isFrozen(TASK_KIND));
});
});
// ═══════════════════════════════════════════════════════════════════════════
// planning-command-router.cjs — pure dispatch, recording mocks, no fs
// ═══════════════════════════════════════════════════════════════════════════
describe('planning-command-router', () => {
function mockError() {
const calls = [];
const fn = (message, reason) => calls.push({ message, reason });
fn.calls = calls;
return fn;
}
function mockInspect() {
const calls = [];
return {
calls,
cmdPlanningInspect(cwd, raw) {
calls.push({ cwd, raw });
},
};
}
test('PLANNING_SUBCOMMANDS is exactly ["inspect"]', () => {
assert.deepStrictEqual(PLANNING_SUBCOMMANDS, ['inspect']);
});
test('dispatches "planning inspect" and forwards cwd/raw verbatim', () => {
const error = mockError();
const mod = mockInspect();
routePlanningCommand({ args: ['planning', 'inspect'], cwd: '/some/cwd', raw: true, error, _planningInspect: mod });
assert.deepStrictEqual(error.calls, []);
assert.deepStrictEqual(mod.calls, [{ cwd: '/some/cwd', raw: true }]);
});
test('forwards a falsy raw and a different cwd verbatim (not defaulted)', () => {
const error = mockError();
const mod = mockInspect();
routePlanningCommand({ args: ['planning', 'inspect'], cwd: '/other', raw: false, error, _planningInspect: mod });
assert.deepStrictEqual(mod.calls, [{ cwd: '/other', raw: false }]);
});
test('a missing subcommand yields sdk_unknown_command and never calls the mock', () => {
const error = mockError();
const mod = mockInspect();
routePlanningCommand({ args: ['planning'], cwd: '/x', raw: false, error, _planningInspect: mod });
assert.strictEqual(error.calls.length, 1);
assert.strictEqual(error.calls[0].reason, 'sdk_unknown_command');
assert.strictEqual(error.calls[0].message, 'Unknown planning subcommand. Available: inspect');
assert.deepStrictEqual(mod.calls, []);
});
test('an unknown subcommand yields sdk_unknown_command and never calls the mock', () => {
const error = mockError();
const mod = mockInspect();
routePlanningCommand({ args: ['planning', 'bogus'], cwd: '/x', raw: false, error, _planningInspect: mod });
assert.strictEqual(error.calls.length, 1);
assert.strictEqual(error.calls[0].reason, 'sdk_unknown_command');
assert.deepStrictEqual(mod.calls, []);
});
test('a stray positional argument is a usage error naming the offender, never dispatched', () => {
const error = mockError();
const mod = mockInspect();
routePlanningCommand({ args: ['planning', 'inspect', 'extra'], cwd: '/x', raw: false, error, _planningInspect: mod });
assert.strictEqual(error.calls.length, 1);
assert.strictEqual(error.calls[0].reason, 'usage');
assert.strictEqual(
error.calls[0].message,
'planning inspect takes no arguments; got positional argument: extra. Usage: gsd-tools query planning inspect',
);
assert.deepStrictEqual(mod.calls, []);
});
test('an unknown flag is a usage error naming it as a flag, never dispatched', () => {
const error = mockError();
const mod = mockInspect();
routePlanningCommand({ args: ['planning', 'inspect', '--nope'], cwd: '/x', raw: false, error, _planningInspect: mod });
assert.strictEqual(error.calls.length, 1);
assert.strictEqual(error.calls[0].reason, 'usage');
assert.strictEqual(
error.calls[0].message,
'planning inspect takes no arguments; got flag: --nope. Usage: gsd-tools query planning inspect',
);
assert.deepStrictEqual(mod.calls, []);
});
test('defaults to the real planning-inspect module when no mock is injected', (t) => {
// No fixtures — buildPlanningInspect degrades gracefully on an absent
// .planning/ dir, so this proves the `mod ?? planningInspect` fallback
// wiring without spawning anything.
const cwd = mkCwd();
t.after(() => cleanup(cwd));
const error = mockError();
routePlanningCommand({ args: ['planning', 'inspect'], cwd, raw: true, error });
assert.deepStrictEqual(error.calls, []);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// planning-inspect.cjs — small on-disk fixtures, in-process buildPlanningInspect
// ═══════════════════════════════════════════════════════════════════════════
describe('planning-inspect — planning root absent', () => {
test('degrades every section to a non-answer with PLANNING_ROOT_ABSENT', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
const result = buildPlanningInspect(cwd);
assert.strictEqual(result.schema_version, 1);
assert.strictEqual(result.generated_from.planning_root, null);
assert.deepStrictEqual(result.phases, []);
assert.deepStrictEqual(result.orphan_phase_dirs, []);
assert.deepStrictEqual(result.requirements, []);
assert.deepStrictEqual(result.progress.accepted_phases, { completed: 0, total: 0, percent: null, scope: 'unreadable' });
assert.deepStrictEqual(result.progress.completed_plans, { completed: 0, total: 0, percent: null, scope: 'unreadable' });
assert.strictEqual(result.milestone.scope, 'unreadable');
assert.deepStrictEqual(diagnosticCodes(result), [
INSPECT_DIAGNOSTIC.PLANNING_ROOT_ABSENT,
INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT,
INSPECT_DIAGNOSTIC.PERCENT_WITHHELD,
INSPECT_DIAGNOSTIC.PERCENT_WITHHELD,
]);
});
});
describe('planning-inspect — healthy two-phase project', () => {
function buildHealthy(cwd) {
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(
["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'],
['## Current Position', '', 'Plan: 1-01-PLAN.md', ''],
));
writeFile(cwd, '.planning/ROADMAP.md', [
'## v1.0 Current 🚧', '', '## Phases', '',
'- [x] **Phase 1: Foo** - stub',
'- [ ] **Phase 2: Bar** - stub',
'',
'### Phase 1: Foo', '', 'Ship the foo module end to end.', '',
'**Depends on:** Phase 0', '',
'### Phase 2: Bar', '', 'Ship the bar module.', '',
].join('\n'));
writeFile(cwd, '.planning/REQUIREMENTS.md', [
'# Requirements: Test', '', '## v1 Requirements', '',
'- [x] **AUTH-01**: User can sign up',
'- [ ] **AUTH-02**: User can log in',
'',
'## Traceability', '',
'| Requirement | Phase | Status |',
'|-------------|-------|--------|',
'| AUTH-01 | Phase 1 | Complete |',
'| AUTH-02 | Phase 2 | Pending |',
'',
].join('\n'));
for (const [token, name] of [['1', 'foo'], ['2', 'bar']]) {
const phaseDir = phaseDirOf(cwd, `0${token}-${name}`);
writeAbs(path.join(phaseDir, `${token}-01-PLAN.md`), frontmatterDoc(['wave: 1'], [
'<objective>', `Ship ${name}`, '</objective>', '',
'<tasks>', '',
'<task type="auto">',
` <name>Task 1: Build ${name}</name>`,
` <files>src/${name}.ts</files>`,
' <done>Done</done>',
'</task>', '',
'</tasks>',
]));
writeAbs(path.join(phaseDir, `${token}-01-SUMMARY.md`), frontmatterDoc(['status: complete'], [
'# Summary', '', '## Files Created/Modified', `- \`src/${name}.ts\` - ${name}`,
]));
writeAbs(path.join(phaseDir, `${token}-VERIFICATION.md`), ['---', 'status: passed', '---', ''].join('\n'));
}
}
test('reports exact scope, percent, requirement, plan-metadata and phase-goal values', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
buildHealthy(cwd);
const result = buildPlanningInspect(cwd);
assert.strictEqual(result.phases.length, 2);
assert.strictEqual(result.milestone.version, 'v1.0');
const [foo, bar] = result.phases;
assert.strictEqual(foo.dir, '01-foo');
assert.strictEqual(foo.phase_id, '01');
assert.strictEqual(foo.complete, true);
assert.strictEqual(foo.scope, 'complete');
assert.deepStrictEqual(foo.goal, { value: 'Ship the foo module end to end.', scope: 'complete' });
assert.deepStrictEqual(foo.dependencies, { value: ['0'], scope: 'complete' });
assert.deepStrictEqual(foo.verification, { status: 'passed', next_action: 'Verification passed — continue.' });
assert.deepStrictEqual(foo.roadmap_acceptance, { checkbox: true, authoritative: false });
assert.strictEqual(bar.dir, '02-bar');
assert.deepStrictEqual(bar.dependencies, { value: [], scope: 'complete' });
assert.deepStrictEqual(bar.roadmap_acceptance, { checkbox: false, authoritative: false });
const [plan] = foo.plans;
assert.strictEqual(plan.id, '1-01');
assert.strictEqual(plan.wave, 1);
assert.deepStrictEqual(plan.dependsOn, []);
assert.strictEqual(plan.hasSummary, true);
assert.deepStrictEqual(plan.changedFiles, ['src/foo.ts']);
// The SUMMARY carries only `## Files Created/Modified` (plan-level), with
// no `## Deviations from Plan` block naming a task — so provenance is
// PLAN_SCOPED, not TASK_SCOPED, and status/agreement are UNKNOWN.
const [task] = plan.tasks;
assert.strictEqual(task.provenance, PROVENANCE.PLAN_SCOPED);
assert.strictEqual(task.agreement, AGREEMENT.UNKNOWN);
assert.strictEqual(task.status, TASK_STATUS.UNKNOWN);
assert.strictEqual(task.changedFiles, null);
assert.deepStrictEqual(result.requirements.map((r) => [r.id, r.complete, r.mappedPhases]), [
['AUTH-01', true, ['1']],
['AUTH-02', false, ['2']],
]);
assert.deepStrictEqual(result.progress.accepted_phases, { completed: 2, total: 2, percent: 100, scope: 'complete' });
assert.deepStrictEqual(result.progress.completed_plans, { completed: 2, total: 2, percent: 100, scope: 'complete' });
assert.strictEqual(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.PERCENT_WITHHELD), false);
});
});
describe('planning-inspect — task provenance/agreement variety, checkpoint, orphan dirs, requirement diagnostics', () => {
function build(cwd) {
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
writeFile(cwd, '.planning/ROADMAP.md', [
'## v1.0 Current 🚧', '', '## Phases', '',
'- [x] **Phase 1: Foo** - stub', '',
'### Phase 1: Foo', '', 'Ship the foo module.', '',
].join('\n'));
writeFile(cwd, '.planning/REQUIREMENTS.md', [
'# Requirements: Test', '', '## v1 Requirements', '',
'- [x] **AUTH-01**: User can sign up',
'- [x] **AUTH-01**: Duplicate row',
'- [ ] **AUTH-02**: Unmapped requirement',
'- [ ] **AUTH-03**: Maps to missing phase',
'', '## Traceability', '',
'| Requirement | Phase | Status |',
'|-------------|-------|--------|',
'| AUTH-01 | Phase 1 | Complete |',
'| AUTH-03 | Phase 9 | Pending |',
'',
].join('\n'));
const p1 = phaseDirOf(cwd, '01-foo');
writeAbs(path.join(p1, '1-01-PLAN.md'), frontmatterDoc(['wave: 1'], [
'<objective>', 'Ship foo', '</objective>', '',
'<tasks>', '',
'<task type="auto">',
' <name>Task 1: Agreed task</name>',
' <files>src/a.ts</files>',
' <done>Done</done>',
'</task>', '',
'<task type="auto">',
' <name>Task 2: Conflicting task</name>',
' <files>src/b.ts</files>',
' <done>Done</done>',
'</task>', '',
'<task type="checkpoint:manual">',
' <decision>Ship it?</decision>',
'</task>', '',
'</tasks>',
]));
writeAbs(path.join(p1, '1-01-SUMMARY.md'), frontmatterDoc(['status: complete'], [
'# Summary', '', '## Files Created/Modified', '- `src/a.ts` - a', '',
'## Deviations from Plan', '',
'**Found during:** Task 1',
'**Files modified:** `src/a.ts`', '',
'**Found during:** Task 2',
'**Files modified:** `src/other.ts`',
]));
writeAbs(path.join(p1, '1-VERIFICATION.md'), ['---', 'status: passed', '---', ''].join('\n'));
fs.mkdirSync(phaseDirOf(cwd, '99-orphan'), { recursive: true });
}
test('agreed vs conflicting task provenance, checkpoint shape, orphan dir, and every requirement diagnostic code', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
build(cwd);
const result = buildPlanningInspect(cwd);
assert.deepStrictEqual(result.orphan_phase_dirs, ['99-orphan']);
assert.strictEqual(result.phases.length, 1);
const [agreedTask, conflictingTask, checkpointTask] = result.phases[0].plans[0].tasks;
assert.strictEqual(agreedTask.provenance, PROVENANCE.TASK_SCOPED);
assert.strictEqual(agreedTask.agreement, AGREEMENT.AGREED);
assert.strictEqual(agreedTask.status, TASK_STATUS.DONE);
assert.deepStrictEqual(agreedTask.changedFiles, ['src/a.ts']);
assert.strictEqual(conflictingTask.provenance, PROVENANCE.TASK_SCOPED);
assert.strictEqual(conflictingTask.agreement, AGREEMENT.CONFLICTING);
assert.deepStrictEqual(conflictingTask.changedFiles, ['src/other.ts']);
assert.deepStrictEqual(conflictingTask.plannedFiles, ['src/b.ts']);
assert.strictEqual(checkpointTask.kind, TASK_KIND.CHECKPOINT);
assert.strictEqual(checkpointTask.provenance, PROVENANCE.PLAN_SCOPED);
assert.strictEqual(checkpointTask.agreement, AGREEMENT.UNKNOWN);
assert.deepStrictEqual(
result.requirements.map((r) => ({ id: r.id, complete: r.complete, mappedPhases: r.mappedPhases, diagnostics: r.diagnostics })),
[
{ id: 'AUTH-01', complete: true, mappedPhases: ['1'], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE] },
{ id: 'AUTH-02', complete: false, mappedPhases: [], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED] },
{ id: 'AUTH-03', complete: false, mappedPhases: ['9'], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN] },
],
);
const codes = diagnosticCodes(result);
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.ORPHAN_PHASE_DIR));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_CONFLICTING));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_SHAPE_CHECKPOINT));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_PLAN_SCOPED));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN));
assert.strictEqual(codes.includes(INSPECT_DIAGNOSTIC.PERCENT_WITHHELD), false);
});
});
describe('planning-inspect — percent withheld, unreadable plan/summary, completion-unknown requirement', () => {
function build(cwd) {
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
writeFile(cwd, '.planning/ROADMAP.md', [
'## v1.0 Current 🚧', '', '## Phases', '',
'- [x] **Phase 1: Foo** - stub',
'- [ ] **Phase 2: Bar** - stub',
'',
'### Phase 1: Foo', '', 'Ship the foo module.', '',
// Deliberately NO section for Phase 2 -> ROADMAP_UNSCOPED for it.
].join('\n'));
writeFile(cwd, '.planning/REQUIREMENTS.md', [
'# Requirements: Test', '', '## v1 Requirements', '',
'- [x] **AUTH-01**: User can sign up',
'',
'## Other', '',
'| AUTH-05 | some description |',
'',
'## Traceability', '',
'| Requirement | Phase | Status |',
'|-------------|-------|--------|',
'| AUTH-01 | Phase 1 | Complete |',
'| AUTH-05 | Phase 1 | Pending |',
'',
].join('\n'));
const p1 = phaseDirOf(cwd, '01-foo');
writeAbs(path.join(p1, '1-01-PLAN.md'), frontmatterDoc(['wave: 1'], [
'<objective>', 'Ship foo', '</objective>', '',
'<tasks>', '', '<task type="auto">', ' <name>Task 1</name>', ' <files>src/a.ts</files>', ' <done>Done</done>', '</task>', '', '</tasks>',
]));
// Directory-in-file-position (cross-platform, no chmod): readDocument sees
// !stat.isFile() and reports unreadable, never a permissions hack.
fs.mkdirSync(path.join(p1, '1-01-SUMMARY.md'), { recursive: true });
const p2 = phaseDirOf(cwd, '02-bar');
fs.mkdirSync(p2, { recursive: true });
fs.mkdirSync(path.join(p2, '2-01-PLAN.md'), { recursive: true });
}
test('withholds percent when a windowed phase has no ROADMAP section, and reports unreadable plan/summary + completion-unknown', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
build(cwd);
const result = buildPlanningInspect(cwd);
const [foo, bar] = result.phases;
assert.deepStrictEqual(foo.goal, { value: 'Ship the foo module.', scope: 'complete' });
assert.deepStrictEqual(bar.goal, { value: null, scope: 'unscoped' });
assert.deepStrictEqual(bar.dependencies, { value: [], scope: 'unscoped' });
assert.strictEqual(bar.scope, 'unscoped');
assert.strictEqual(bar.plans[0].scope, 'unreadable');
assert.strictEqual(bar.plans[0].tasks.length, 0);
assert.deepStrictEqual(result.progress.accepted_phases, { completed: 0, total: 2, percent: null, scope: 'unscoped' });
assert.strictEqual(result.progress.completed_plans.percent, null);
const auth05 = result.requirements.find((r) => r.id === 'AUTH-05');
assert.strictEqual(auth05.complete, 'unknown');
assert.deepStrictEqual(auth05.mappedPhases, ['1']);
assert.deepStrictEqual(auth05.diagnostics, [INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN]);
const codes = diagnosticCodes(result);
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.SUMMARY_UNREADABLE));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PLAN_UNREADABLE));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN));
assert.strictEqual(codes.filter((c) => c === INSPECT_DIAGNOSTIC.PERCENT_WITHHELD).length, 2);
});
});
describe('planning-inspect — UAT unreadable, UAT items, requirements unreadable, containment escape', () => {
test('a directory-in-file-position UAT document is UAT_UNREADABLE and degrades phase scope', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
writeFile(cwd, '.planning/ROADMAP.md', [
'## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '',
'### Phase 1: Foo', '', 'Ship the foo module.', '',
].join('\n'));
const p1 = phaseDirOf(cwd, '01-foo');
fs.mkdirSync(path.join(p1, '1-UAT.md'), { recursive: true });
const result = buildPlanningInspect(cwd);
assert.deepStrictEqual(result.phases[0].uat, { unresolved: [], scope: 'truncated' });
assert.strictEqual(result.phases[0].scope, 'truncated');
const codes = diagnosticCodes(result);
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.UAT_UNREADABLE));
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED));
});
test('a pending UAT test item is surfaced verbatim in phases[].uat.unresolved', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
writeFile(cwd, '.planning/ROADMAP.md', [
'## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '',
'### Phase 1: Foo', '', 'Ship the foo module.', '',
].join('\n'));
const p1 = phaseDirOf(cwd, '01-foo');
fs.mkdirSync(p1, { recursive: true });
writeAbs(path.join(p1, '1-UAT.md'), [
'# UAT: Phase 1', '',
'## Current Test', '[testing complete]', '',
'## Tests', '',
'### 1. Sign up flow',
'expected: user can sign up',
'result: pending',
'',
].join('\n'));
const result = buildPlanningInspect(cwd);
assert.deepStrictEqual(result.phases[0].uat, {
scope: 'complete',
unresolved: [{ test: 1, name: 'Sign up flow', expected: 'user can sign up', result: 'pending', category: 'pending' }],
});
});
test('REQUIREMENTS.md as a directory-in-file-position is unreadable, not absent, and yields zero rows', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
writeFile(cwd, '.planning/ROADMAP.md', ['## v1.0 Current 🚧', '', '## Phases', ''].join('\n'));
fs.mkdirSync(path.join(cwd, '.planning/REQUIREMENTS.md'), { recursive: true });
const result = buildPlanningInspect(cwd);
assert.deepStrictEqual(result.requirements, []);
assert.ok(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.REQUIREMENTS_UNREADABLE));
assert.strictEqual(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT), false);
});
test('a plan file symlinked outside the planning root degrades to unreadable, never leaking the escaped content', (t) => {
const cwd = mkCwd();
t.after(() => cleanup(cwd));
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
writeFile(cwd, '.planning/ROADMAP.md', [
'## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '',
'### Phase 1: Foo', '', 'Ship the foo module.', '',
].join('\n'));
const p1 = phaseDirOf(cwd, '01-foo');
fs.mkdirSync(p1, { recursive: true });
const outside = path.join(os.tmpdir(), `planning-inspect-unit-outside-${process.pid}.md`);
fs.writeFileSync(outside, 'SECRET CONTENT');
t.after(() => cleanup(outside));
fs.symlinkSync(outside, path.join(p1, '1-01-PLAN.md'));
const result = buildPlanningInspect(cwd);
const [plan] = result.phases[0].plans;
assert.strictEqual(plan.scope, 'unreadable');
assert.strictEqual(plan.objective, null);
assert.deepStrictEqual(plan.tasks, []);
const json = JSON.stringify(result);
assert.strictEqual(json.includes('SECRET CONTENT'), false);
assert.ok(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.PLAN_UNREADABLE));
});
});

View File

@@ -297,6 +297,14 @@ describe('parseRequirements — checkbox-bullet characterization (T3 pre-migrati
assert.strictEqual(items[0].text, 'Checked requirement'); assert.strictEqual(items[0].text, 'Checked requirement');
}); });
test('strips the separator colon used by the shipped requirements template', () => {
const md = '- [ ] **AUTH-01**: User can sign up\n';
const items = parseRequirements(md);
assert.strictEqual(items.length, 1);
assert.strictEqual(items[0].id, 'AUTH-01');
assert.strictEqual(items[0].text, 'User can sign up');
});
test('parses multiple checkbox bullets', () => { test('parses multiple checkbox bullets', () => {
const md = [ const md = [
'- [ ] **REQ-01** First', '- [ ] **REQ-01** First',