Files
msd-core/CONTEXT.md
Tom Boucher c3e2da153b fix(#4134): refuse punctuation-only milestone heading names (#4358)
* test(#4134): fail-first regression — refuse punctuation-fragment milestone names

A first-milestone ROADMAP.md H1 that puts the version after the name
(# Roadmap: Project — Name (v1.13)) leaves exactly ')' after the heading's
own version token, which the ADR-3180 §7.2 pinned name rule returns as a
COMPLETE-scope milestone name. Failing-first coverage:

- getMilestoneInfo: name-then-version H1 (STATE-anchored + ROADMAP-only
  fallback) must yield TRUNCATED {version, name: null}, never ')'
- the refusal is level-agnostic (H2/H3)
- punctuation-family remainders (')', '()', '**', '.,;:', ']}', emoji-only)
- listMilestoneHeadings enumerates the heading with name: null
- init manager CLI reports milestone_name: null and no lone ')' anywhere
- property (seed 20260905, 300 runs): a word-char remainder is always a
  name, a punctuation-only remainder never is
- negative space: canonical delimiter forms, parenthetical names (#3171),
  trailing markers, digit-only names, CRLF headings, version-last-no-parens
  control

* fix(#4134): refuse punctuation-only milestone heading names

extractMilestoneHeadingName returns everything after the heading's own
version token as the name (ADR-3180 §7.2 pinned rule), which assumes
version-then-name. A name-then-version heading — the H1 a first-ever
ROADMAP.md drifts into ('# Roadmap: Project — Name (v1.13)') — leaves
exactly ')' after the token, and that fragment was returned as a
COMPLETE-scope milestone name, propagating into init.* JSON output and
buildStateFrontmatter's STATE.md writes.

A remainder with no letter or digit anywhere (any script) is heading
structure, not a curated name: refuse it as name: null so callers report
the honest §7.2 rule-6 answer (version kept, TRUNCATED scope). Names
that merely contain punctuation are unaffected — '(' stays an ordinary
name character (#3171) — and digit-only names qualify.

Also closes the template gap that lets the shape occur: the roadmapper
agent's output_formats now templates the version-free canonical H1
('# Roadmap: [Project Name]', per templates/roadmap.md) instead of
leaving a first milestone's title line to invention. The new section
shifts the file's existing bare-gsd-tools prose mention from line 647
to 660, so its line-keyed PROSE_ALLOWLIST entry moves with it.

Emitted-Drift-Ack-Growth: gsd-roadmapper.md — deliberate +498 bytes: new '### 0. Top-Level Title (H1)' output_formats section templating the canonical version-free H1, closing the first-milestone template gap that lets an H1 drift into 'Name (vX.Y)' and corrupt milestone_name extraction (#4134)

* chore(#4134): add changeset

* chore(#4134): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
2026-09-05 23:31:35 -04:00

1034 lines
462 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Context
> **Format**: this document is machine-greppable. Each operational fact is a
> single-line predicate (`CLASS.subkey=value`). Agent briefs cite predicates
> by ID verbatim (per `META.RULE.brief-must-cite-doc`) — never paraphrase from
> this file. New learnings go in as predicates; chronological prose belongs
> in the session log at the bottom.
## Glossary — Domain modules and seams
### Milestone Module
Module owning `milestone complete` (archive roadmap/requirements/phases, build MILESTONES.md entry, update STATE.md), `requirements mark-complete` (checkbox + table update with regex-global-state fix), and `phases clear`. Key behaviors: milestone-phase scoping (extract phases from ROADMAP.md milestone slice, support project-code-prefix dirs e.g. CK-01-name, exclude prior-milestone phases), milestone-archive layout (resolve phase dirs from `.planning/milestones/v*-phases/` when `.planning/phases/` absent), fenced-code-block boundary tracking in `extractCurrentMilestone`. Source of truth: `gsd-core/bin/lib/milestone.cjs` (query handlers for `milestone.complete`, `phases.archive`, `milestone.archive-quick`). `milestone.archive-quick` (#2142 escalation) is the narrow archival-only helper `gsd-core/workflows/cleanup.md` uses instead of `milestone.complete --archive-quick` — it shares `milestone.complete`'s quick-task move/README-index logic but skips the ROADMAP/REQUIREMENTS/MILESTONES.md writes and the milestone-completion guards, so it is safe to run against an already-completed milestone. Test consolidation: PR #3753 (10 files → 4). (The SDK milestone surface and `GSD.run()` milestone runner were retired with the SDK package per ADR-0174.)
### Dispatch Pipeline Module
Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. The SDK-era pipeline collapsed onto the Command Routing Hub per ADR-0174; current dispatch seam: `gsd-core/bin/lib/command-routing-hub.cjs` (see Command Routing Hub below).
### Phase Id Module
Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, canonical phase-directory selection, milestone- and phase-dir id parsing, phase-markdown regex builders, and the ADR-612 bracket phase-id round-trip grammar (`escapeRegex`, `normalizePhaseName`, `comparePhaseNum`, `extractPhaseToken`, `phaseTokenMatches`, `matchPhaseDirs`, `phaseNumberForMatch`, `phaseMarkdownRegexSource`/`phaseMarkdownRegexSourceExact`, `getMilestoneFromPhaseId`, `getPhaseDirFromPhaseId`, `parsePhaseId`/`renderPhaseId`/`toDir` over the `PhaseId` type, `isSentinelPhaseId`/`SENTINEL_RANGES`, and the `BRACKET_PHASE_TOKEN_SOURCE`/`PHASE_HEADING_PREFIX_SRC` grammar sources, plus the #612 read-path surface: the one bracket identity grammar `BRACKET_ID_SRC`/`BRACKET_MILESTONE_NUMERIC_SRC`/`BRACKET_DIR_PREFIX_SRC`, the case-folding `foldBracketId`, `bracketQualifiedKey`, and the convention-gated heading-intro selector `phaseHeadingPrefixSrcFor` over `PHASE_HEADING_BASELINE` / `BASE_ANY_BRACKET_HEADING_PREFIX_SRC` / `BASE_PHASE_LABEL_PREFIX_SRC`). Also owns the canonical phase KEY surface (#2562) — `phaseKeyFromToken`/`phaseKeyFromDir`/`phaseKeyFromProse`/`parentPhaseKey` — the padding-, case- and project-code-insensitive identity used whenever two independently-derived phase references (a ROADMAP table cell and a phase directory, say) are compared; deriving one side of such a comparison with a bespoke regex is what silently zeroed a rollup in #2562. Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-id.cjs` (generated from `src/phase-id.cts`).
### Phase Lifecycle Module
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.)
### Phase Estimation Module
Module owning phase-effort estimation and its calibration against measured reality (ADR-2629, epic #1952). Pure — no I/O, no config reads; the CLI seam (`src/estimate-cli.cts`, verbs `estimate-check` / `estimate-calibration`) owns reading `.planning/config.json` and `.planning/estimation-calibration.json`. Interface: `parseEstimate`/`renderEstimate` (the PLAN.md `estimate: {tokens, tasks, confidence}` block), `parseActuals`/`renderActuals` (the SUMMARY.md `actuals: {tokens, tasks, commits}` block), `deriveConfidence(sampleCount) → low|med|high`, `classifyAgainstBudget(estimate, budget) → {overBudget, ratio, recommendation, budgetValid}`, `computeCalibration(samples) → {factor, sampleCount, applied, confidence, clamped}`, `applyCalibration`, `parseCalibrationDocument`/`renderCalibrationDocument`, `extractFrontmatterBlock` (leading-`---`-anchored scalar-block reader; stays hand-rolled for its TYPE CONTRACT — it returns numeric-looking values as numbers so `parseEstimate`/`parseActuals` see the types they validate, whereas the migrated `extractFrontmatter`, ADR-3473 §8.1/#3881, resolves every scalar as a string under js-yaml's FAILSAFE_SCHEMA; migrating this function onto the shared parser is follow-on work under ADR-3473 §8.1, not done), `calibrationBasis` (returns `estimate.raw_tokens` when present, else `tokens` — calibration must measure actual/raw or the loop un-corrects itself), and `measureTokens` (a re-export of `prompt-budget`'s `estimateTokens`). **Domain terms: _raw_ vs _calibrated_ tokens** — the same token count in two mutually incompatible states, carried by the compile-time brands `RawTokens` (the planner's uncorrected projection, and the only legal calibration denominator) and `CalibratedTokens` (the projection with the project's factor applied, and the only figure meaningful against the budget), constructed at trust boundaries via `asRawTokens` / `asCalibratedTokens` (#2671). The brands erase at compile time — the emitted `.cjs`, the CLI JSON, and both frontmatter schemas are unchanged — and exist because mixing the two states was NOT catchable at runtime: both are positive integers of the same magnitude, and the mix-up shipped twice past a green ~26,800-test suite (#2631 factor², #2632 self-defeating loop). `asRawTokens` refuses a `CalibratedTokens` by design; the single legitimate crossover (a pre-#2632 plan whose `tokens` IS the raw projection) lives behind one commented assertion in `calibrationBasis`. Compile fixtures: `tests/fixtures/brand-typing/`. **_smart zone_** — the usable prefix of a model's context window before output quality degrades, expressed as the configurable `workflow.smart_zone_tokens` budget (default 100000, a *policy default* rather than a benchmark constant since the effective ceiling is model/task-dependent); **_estimate/actuals_** — a projected phase cost recorded at plan time and the measured cost recorded at completion, both on the **same `estimateTokens` scale** so their ratio measures the miss rather than a difference between two measurement methods. Two invariants: (1) every signal is **exogenous** — the correction routes on a measured actual/estimate ratio and `confidence` routes on a calibration sample count, never on a model's self-assessment (this project measured self-rated confidence and found it weak — `gsd-core/references/honest-verifier.md:25-29`; see `.out-of-scope/general-purpose-agent-prompt-skills.md`); (2) the over-budget flag is **advisory** — a warning plus a split recommendation, never a block. Calibration is median-of-ratios, clamped to `[0.5, 3.0]`, and inert below 3 samples. CLI seam verbs: `estimate-check` (classify one figure; `--calibrated` when the input already has the factor applied — omitting it squares the correction), `estimate-calibration` (report the current factor), `estimate-calibrate` (#2632 — pair every completed phase's PLAN `estimate` with its SUMMARY `actuals`, rebuild `.planning/estimation-calibration.json` idempotently, and report the result; this is what closes the loop). Source of truth: `gsd-core/bin/lib/phase-estimation.cjs` and `src/estimate-cli.cts`. Test anchors: `tests/phase-estimation.test.cjs`, `tests/estimate-calibrate.test.cjs`.
### Verification Module
Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. `readVerificationStatus(phaseDir, opts?)` reads the first `*-VERIFICATION.md` frontmatter `status`, maps it through `VERIFICATION_ROUTING_TABLE`, and fail-closes — only `{passed}` satisfies the canonical gate; `missing`/`unknown`/`gaps_found`/`human_needed`/`stale` all route away from "complete" (#1522). Staleness is one of TWO mutually exclusive checks, selected by whether the report DECLARES a fingerprint (#4155, i.e. either `covered_files` or `covered_digest` is present — a report that declares only one, or an empty/malformed pair, fails closed to `stale` rather than silently downgrading to the weaker legacy check): a report with a well-formed pair is checked by `computeCoveredDigest(projectRoot, coveredFiles)` — a deterministic sha256-of-sha256s over the SORTED, de-duplicated, `./`/`..`-canonicalized covered set, hashed by file BYTES always through the real `node:fs`, never a caller-injected `opts.fs` seam (`covered_files` spans the whole `projectRoot`, not just `.planning/`, so a caller-scoped containment wrapper narrower than `projectRoot` would reject legitimate covered files; the `realRel`-vs-`realRoot` re-check inside `computeCoveredDigest` is the actual confinement boundary, against `projectRoot`) — recomputed and compared to the stored digest. `allCurrentArtifactsCovered` additionally re-scans the LIVE phase directory (via `scanPhasePlans`, short-circuited so it only runs once the digest itself has matched, and fails closed to `false` on any scope other than `SCOPE.COMPLETE`) for every current `*-PLAN.md`/`*-SUMMARY.md` and requires each to be represented in `covered_files`: a plan or summary added to the phase AFTER verification, never declared, would otherwise pass the digest check untouched. ANY mismatch — digest, an uncovered current artifact, or a covered path that is missing, unreadable, or escapes `projectRoot` (`findProjectRoot(phaseDir)`, canonicalized via real `fs`, never the injected seam — it is a trusted caller-derived anchor, not attacker-influenced covered-input data) — routes to `stale`, fail-closed (`computeCoveredDigest` returns `null` for the whole set rather than a partial digest). A report with no fingerprint metadata at all (every report predating #4155) falls back to the legacy `findStaleVerificationSummary`, which flags a SUMMARY newer than the VERIFICATION file — unchanged. The verifier computes `covered_digest` via the CLI (`verification.fingerprint <phaseDir> <file>...`, routed in `verification-command-router.cjs`), never by hand — this is deterministic math, not an LLM-estimated value. The legacy path alone keeps the module's original no-throw, degrade-to-safe contract (any FS error → `missing` / not-stale); the fingerprint path's contract is stricter (any FS error → `stale`), matching #4155's fail-closed design. `isPhaseComplete(phaseDir, deps?)` is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186, disk-strict per #2957): it wraps `readVerificationStatus`, calling it UNCONDITIONALLY — plan count is never a precondition, so a zero-plan phase with a passing `*-VERIFICATION.md` is complete (#3168) — and returns `{ value: { complete, verification }, scope }`; `complete` is exactly `verification.status === 'passed'`. A ROADMAP checkbox carries no machine authority and is never consulted. `cmdPhaseComplete`, `buildPhaseCompletionProjection`, and `buildStateFrontmatter` all route through it. Source of truth: `gsd-core/bin/lib/verification.cjs` (generated from `src/verification.cts`).
`SEAM.verification-isphasecomplete.owns=isPhaseComplete(phaseDir, deps?) is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186)`
`SEAM.verification-isphasecomplete.enforced-by=test:tests/verification-status.test.cjs`
### Verify Command Grounding Module
Module owning the deterministic resolvability probe over PLAN.md `<automated>` verify commands (#2401), plus the prior-phase command harvest that feeds the planner. `extractAutomatedCommands(planText)` pulls every `<automated>…</automated>` body with its owning `<task><name>`, in document order, via a ReDoS-safe stop-at-next-open task pattern (shape mirrors `PLAN_TASK_BLOCK_RE` in `verify.cjs`) and a monotonic span pointer; non-string input yields `[]`. `resolveVerifyCommandTarget(command, {projectRoot, declaredPaths})` is a **RECOGNIZER, not a shell interpreter** (deliberate, per Greenspun): it grounds exactly two forms — a folded leading `cd <literal>` chain and `npm --prefix <literal>` — and any path carrying `$`, a backtick, `*`, `?`, `~`, or a newline returns `unresolvable`/`dynamic_path` at WARNING severity, never BLOCKER. Status is a closed 5-atom enum (`ok`/`broken`/`unresolvable`/`not_applicable`/`pending_creation`) and severity a closed 3-atom enum (`blocker`/`warning`/`none`); `broken` is only ever `missing_dir` or `no_manifest`, while `script_missing`/`manifest_unreadable`/`outside_root` stay advisory on an `ok` status. A target an earlier task in the same phase declares (`<files>` or the `## Artifacts this phase produces` section) is `pending_creation`, never a blocker — without that, every greenfield phase would red. A bare ancestor climb (`cd ../..`, every segment `..`) short-circuits to `outside_root` without touching the filesystem, because the checker's root and a parallel executor worktree's root differ; a climb naming a concrete sibling (`cd ../../frontend` — the exact #2401 shape) still names something checkable and is probed normally. **The module never executes command text** (`fs.statSync`/`existsSync`/`readFileSync`/`readdirSync` only — PLAN.md is model-authored untrusted input) and deliberately exposes **no `suggestion` field**: prescribing a replacement path is the failure being fixed, not the fix. `probePhaseVerifyCommands({phaseDir, projectRoot})` backs `gsd-tools check verify-command-paths <N>` (routed in `check-command-router.cjs`), degrading to a populated `readError` rather than throwing — an empty `commands` with a non-empty `readError` means *could not look*, not *nothing to report*. `harvestPriorVerifyCommands({planningDir, beforePhase, limit=20, lookback=3})` walks descending phase dirs for the nearest prior phase with any command, deduped first-seen and capped, and is emitted as `init.plan-phase`'s `prior_verify_commands` **ungated by `context_window`** — the `>= 500000` enrichment gate is exactly what starved the planner at 200k. **Failing-direction probe (#3172), the module's second concern.** Every runnable `<automated>` must carry a `<fails_when>` sibling naming what output constitutes failure; a command with no expressible failure mode is not an acceptance test. `extractFailingDirections(planText)` recovers `<automated>` and `<fails_when>` in ONE document-order pass (a single backreferenced alternation — two independent scans would discard the relative positions the pairing walk needs) over the SAME text units as `extractAutomatedCommands` (each `<task>` body, then the task-stripped remainder), sharing that function's `MAX_BLOCK_WALK` guard, `MISSING_SENTINEL_RE` and task grammar rather than copying them (`DEFECT.GENERATIVE-FIX-DIVERGENCE`); pairing never crosses a task boundary. Each `<fails_when>` binds to the nearest PRECEDING `<automated>`, FIRST-WINS — a redundant second statement for one command is ignored and adds no row, and a statement preceding every command is an `orphan` WARNING, never a blocker. `resolveFailingDirection(command, statement)` is the single verdict implementation: status is a closed 6-atom enum (`ok`/`missing`/`empty`/`placeholder`/`sentinel`/`orphan`) over the same 3-atom severity enum, and check ORDER is load-bearing — empty command first, then the `MISSING` Wave-0 sentinel (exempt at `none` even when a statement IS present, because it is not runnable and without that exemption every greenfield phase would red), then missing/empty/placeholder. The placeholder set (`tbd`/`todo`/`n/a`/`na`/`none`/`unknown`/`tba`/`?`/`-`) matches the WHOLE trimmed value case-insensitively, never a substring: `"TBD in the harness output"` is real prose and passes. **PRESENCE only** — whether a statement names the RIGHT signal stays plan-checker judgment at WARNING, so every BLOCKER is deterministic and reproducible. The probe never prescribes a statement, for the same reason it never prescribes a path: a prescribed one is copied verbatim and carries zero information. `probePhaseFailingDirections({phaseDir})` backs `gsd-tools check verify-failure-directions <N>`, degrading to a populated `readError` with top-level `status: 'unresolvable'` — an empty `commands` with a non-empty `readError` means *could not look*, not *nothing to report*. The #2401 path-probe surface is deliberately NOT overloaded (its 5-atom status and counts are unchanged by a missing statement) so `docs/how-to/resolve-verify-command-path-findings.md` stays true. Source of truth: `gsd-core/bin/lib/verify-command-grounding.cjs` (generated from `src/verify-command-grounding.cts`). Design: `.gsd/phase/feat-3172-stated-failing-direction/40-design.md`.
### Phase Locator Module
Module owning phase-directory search and location: active-phase discovery against the `.planning/phases/` tree (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against the filesystem. Depends only on leaf modules (`phase-id` for token/name matching, `core-utils` for fs-scan/path helpers, `planning-workspace` for `planningDir`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2d (#881); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-locator.cjs` (generated from `src/phase-locator.cts`). Since #2830, `searchPhaseInDir` also parses each plan's `depends_on` and each completed plan's SUMMARY `status` and calls Plan Dependency Graph Module's `computeHaltPropagation` to populate `halted_plans`/`blocked_by`/`runnable_plans` — additive fields; `incomplete_plans` keeps its pre-#2830 meaning unchanged. Since #3185 (ADR-3180 Decision 1, Phase 3), the module also owns `listMilestonePhaseDirs(phasesDir, { cwd, ws, versionOverride, phaseIdConvention })`, the single canonical owner of milestone-scoped phase-directory enumeration: it applies the current milestone's `ROADMAP.md` window (via `getMilestonePhaseFilter`) and then the canonical `isSentinelPhaseId` sentinel filter, in that order, over the raw `phasesDir` directory listing. It returns `{ value: string[], scope }`, where `scope` is the `SCOPE` enum from `src/planning-scope.cts` (`complete`/`truncated`/`unscoped`/`unreadable`), so a caller can distinguish a genuinely empty milestone from an enumeration that could not be scoped. Consumed by `query progress`, `stats`, and the bare `phases list`, all of which need "which phases belong to this milestone." `phases list --phase` and `--include-archived` (lookup/archive questions) read the unscoped physical directory set and do not call this owner. `phases clear` and `milestone complete`'s phase-archival move call `isSentinelPhaseId` directly instead — they must sweep every non-sentinel phase directory regardless of milestone window, so they take the sentinel filter without this owner's window scoping. **Note that combination is obtainable from the owner itself**: called with no `cwd`, `listMilestonePhaseDirs` applies no window (`inWindow = () => true`) while still refusing sentinels unconditionally — "sentinels are never milestone phases" — so an all-milestones, sentinel-free enumeration needs no separate implementation. That is the call `collectCalibrationSamples` was missing.
`SEAM.phase-locator-milestone-enum.owns=listMilestonePhaseDirs(phasesDir, opts) is the single canonical owner of milestone-scoped phase-directory enumeration (ADR-3180 Decision 1, Phase 3, #3185)`
`SEAM.phase-locator-milestone-enum.enforced-by=test:tests/phase-locator.test.cjs`
Since #3882 (ADR-3473 §8.2) the module also owns **`listAllPhaseDirs(phasesDir, { includeSentinels, phaseIdConvention })`** — the OTHER axis, the physical directory set with sentinel inclusion **stated rather than implied**. `includeSentinels` is required with no default, so sentinel inclusion cannot be obtained by omission; §8.2's *"a caller that wants sentinels asks for them explicitly"* is enforced at compile time, not documented against. It mirrors the owner's absent-vs-unreadable handling and returns the same `{ value, scope }` shape, so the discriminator survives. This is what the lookup-index callers (`cmdRoadmapAnalyze`'s `_phaseDirNames`, `cmdInitMilestoneOp`'s `diskPhaseDirs`) now call instead of hand-rolling a `readdirSync`, and what `buildAllPhaseDirNamesField` (Planning Snapshot Module) delegates to — that function was a near-duplicate of this combination differing only in sort order, and now re-applies its own lexicographic sort over the owner's result because W007's output order is observable. Exactly one `readdirSync` over the phases directory remains across the two modules.
### Plan Dependency Graph Module
Module owning the single halt-propagation engine over a plan's `depends_on` DAG (#2830). **Domain term: _halted_** — a plan that reached a designed stop (a gate failure, a spike concluding without expanding, or any other intentional non-completion) and wrote a SUMMARY recording that fact via `status: halted` in its frontmatter, as opposed to `status: complete` (ordinary finish) or no SUMMARY at all (not yet attempted). **Domain term: _blocked_** — a plan whose `depends_on` chain reaches a halted plan, directly or transitively; distinct from merely _incomplete_ (no SUMMARY yet) — an ordinary in-progress/not-yet-started dependency does not block. `computeHaltPropagation(nodes: {id, resolvedDependsOn, halted}[])` performs exactly one Kahn's-algorithm topological pass and returns `{order, visited, blockedBy}`, where `blockedBy` maps a plan id to the de-duplicated set of halted plan ids transitively upstream of it (diamond-safe, any depth). This is the SHARED engine both of the two independent "which plans are incomplete" readers call — `phase.cts`'s wave-grouping (`cmdPhasePlanIndex`) and `phase-locator.cts`'s phase-location primitive (`searchPhaseInDir`) — so the two-implementation divergence that caused #2830 (one parsed `depends_on` for waves only, the other never parsed it at all) cannot recur: each caller resolves its own raw `depends_on` tokens to canonical ids before calling in, but the graph traversal itself exists in exactly one place. Pure — no I/O, no config; each caller does its own file reads (a plan's frontmatter, a completed plan's SUMMARY `status`) and fails open (treats an unreadable/malformed file as "not halted"/"no deps") rather than throwing. Source of truth: `gsd-core/bin/lib/plan-dependency-graph.cjs` (generated from `src/plan-dependency-graph.cts`).
### File Overlap Partitioner Module
Generic, dependency-free greedy first-fit file-overlap partitioner (#3674), extracted from `claude-orchestration.cts`'s `partitionStages` (#1143) as a behavior-preserving move — same algorithm, same output, no improvement. `partitionByFileOverlap(items: {id, files}[]) → string[][]` places each item into the earliest stage where it does not share a `files` entry with any item already there (in input order); an item with an empty `files` array overlaps nothing and coalesces into stage 0. Deliberately narrow scope, matching the ADR-1239 "Quick-batch binding" design lock: no dependency-DAG ordering (a caller resolves dependency order before calling in), no path normalization (`Foo.ts` vs `foo.ts`, or a forward-slash path vs its backslash-separator equivalent, compare as distinct files by exact string equality), and no filesystem access. Not guaranteed optimal — greedy first-fit can leave a smaller packing on the table for chain-overlap inputs (A∩B, B∩C, A∌C), which is `partitionStages`' own long-standing, intentional trade-off, preserved rather than "fixed" during extraction. `claude-orchestration.cts`'s `partitionStages` is now a thin adapter mapping its own `Plan[]` shape onto this module's generic `{id, files}[]` input and back, so `emitWorkflowScript`'s `files_modified` overlap → separate sequential stages behavior is unchanged. Exists so a future consumer (quick-batch, #3675) can partition its own planned-path items without pulling in orchestration internals. Pure, zero external dependencies, never throws. Source of truth: `gsd-core/bin/lib/file-overlap-partitioner.cjs` (generated from `src/file-overlap-partitioner.cts`). Tests: `tests/file-overlap-partitioner.test.cjs`, `tests/file-overlap-partitioner.property.test.cjs`.
### Quick-Batch Core Primitives Module
Pure/state primitives and CLI-testable core operations for batching several `/gsd:quick`-shaped tasks (#3675, part of epic #3344, ADR-1239 "Quick-batch binding" §"Quick-batch binding"). NO agent dispatch, NO worktree creation, NO user-facing command — Phase 4/#3676's job; this phase only ADDS new call sites onto four existing, unmodified primitives (`cmdInitQuick`'s quick-id grammar, `appendQuickTaskRow`, `withPlanningLock`, `partitionByFileOverlap`). `parseTaskList`/`parseTaskListFromFile` parse inline bulleted/numbered task lists (≥2 items) or a `--file` variant strictly confined to the planning workspace root via `requireSafePath`, rejecting a directory/socket/device/FIFO target. `allocateQuickIds`/`createBatch` preallocate N distinct `YYMMDD-xxx` quick ids (the SAME grammar `cmdInitQuick` uses, replicated here rather than delegated to it — that function's own 2-second granularity is not collision-safe under batch allocation) under `withPlanningLock`, checked against both on-disk `.planning/quick/` entries and sibling `.planning/quick-batches/*/BATCH.json` manifests (on-disk-only would miss another in-flight batch that hasn't dispatched any real quick directory yet — the concurrency-safety property two sequential lock-serialized `createBatch` calls actually need). `computeWaves` combines dependency-DAG layering (Kahn-style topological depth) with `partitionByFileOverlap` called once PER DAG LAYER over path-separator-normalized `planned_files` (normalization — a backslash-separated and forward-slash-separated form of the same relative path compare equal — happens at THIS module's boundary, never inside the Phase 2 helper, which stays exact-string per its own design lock) — a dependency's wave always strictly precedes its dependent's, and file-overlap freedom only splits SAME-layer items further, never merges across layers. `BATCH.json` lives at `.planning/quick-batches/<batch-id>/BATCH.json` — a SIBLING of `.planning/quick/`, never inside it, so `scanQuickTasks` (`audit.cts`) never misreads a batch manifest as a broken/incomplete quick task. `loadBatch` fails closed on corrupt/truncated JSON, a schema violation, an out-of-batch dependency reference, a dependency cycle, or an item referencing a worktree path absent from disk — never guesses partial state. `resumeBatch` skips `complete` items, never auto-retries a `failed` item, propagates/reverses `blocked` status along the DAG to a fixed point, and — the crash-window case — detects a STATE.md row that already exists (via `hasQuickTaskRow`, keyed on quick id) for a non-complete item and marks it complete WITHOUT re-appending; idempotent across repeated calls on an unchanged manifest. `completeQuickItem` is the exactly-once STATE.md completion primitive: `appendQuickTaskRow` (unmodified, itself carries no idempotency) is called at most once per quick id, gated by `hasQuickTaskRow` re-parsing the real "Quick Tasks Completed" table before ever appending. **`updateBatchItems` (#3676, Phase 4)** is the ONE additive post-planning mutator resolving the design doc's Open Question 1 — the design doc originally proposed a SECOND, independent `withPlanningLock` writer against the same `BATCH.json` path, which this phase deliberately rejected in favor of one additive export on this SAME module: it applies each update's caller-resolved (already-canonical `quick_id`) `dependsOn`/normalized `plannedFiles` onto the matching item, recomputes `wave` for every item via the SAME `computeWaves` (never a duplicate), and fails closed WITHOUT persisting anything on an unknown item, an unknown/self dependency, or an introduced cycle — inside the SAME `withPlanningLock` transaction shape `resumeBatch`/`completeQuickItem` already use, and the exact `platformWriteSync(...,JSON.stringify(manifest,null,2)+'\n')` write call every other mutator here uses, so there is only ever ONE writer of `BATCH.json`'s bytes. Source of truth: `gsd-core/bin/lib/quick-batch.cjs` (generated from `src/quick-batch.cts`). Tests: `tests/quick-batch.test.cjs` (includes `updateBatchItems` coverage, appended rather than a third standalone file — `scripts/lint-test-file-count.cjs` buckets any `quick-batch-*.test.cjs` file under this same module, which is already at its 2-file cap), `tests/quick-batch.property.test.cjs`.
### Quick-Batch Dispatch Core Module
Pure decision logic for `/gsd:quick-batch` (#3676, Phase 4 of epic #3344, ADR-1239 "Quick-batch binding"). This module answers "what should happen next" — it NEVER performs `Agent()` dispatch or `git worktree` I/O (those live in the workflow markdown, a separate follow-up pass); its one exception is `buildCleanupManifestEntry`, which only parses caller-supplied plan text via the existing `parsePlanDocument`, no filesystem access. `parseQuickBatchArgs` validates `--jobs auto|N` (rejects non-numeric/≤0 `N`), `--validate`, `--research`, `--resume <batch-id>` and rejects `--discuss`/`--full` outright (v1 exclusion — presence alone is sufficient, even mixed with otherwise-valid flags) before any dispatch. `computeEffectiveConcurrency({jobs, taskCount, capacity, isolation, mutating})` is `capacity` alone for `--jobs auto`, `min(taskCount, jobsN, capacity)` for `--jobs N`; `isolation === 'none'` forces a **mutating** wave's concurrency to 1 regardless of `--jobs`/capacity — a non-mutating (research-only) wave is explicitly NOT subject to that cap, which is why `mutating` is a separate, caller-supplied boolean rather than inferred. `computeMergeOrder(waveOrder, readyIds)` returns the maximal READY PREFIX of `waveOrder` — merges apply in the deterministic order `computeWaves` assigned, never completion order, so an out-of-order finisher (e.g. item 2 before item 1) waits until every item before it in `waveOrder` is also ready. `computeSpawnPlan({eligibleIds, capacity, currentInFlight, refused})` models spawn backpressure: a refused or capacity-exhausted id returns to `pending` — never counted against capacity, never marked `failed`, never increases fan-out beyond `capacity - currentInFlight`. `routeVerificationOutcome`/`routeMergeOutcome` are small, explicit state-transition functions (deliberately not one giant dispatcher): `human_needed` is terminal (no `completeQuickItem` call); `gaps_found` fails the item with no rollback and no automatic retry; a `merge_failed` or `scope_violation` merge outcome fails the item and always signals `preserveWorktree: true` — this module never signals worktree removal. `buildCleanupManifestEntry` derives a `worktree.cleanup-wave` entry's `files_modified`/`declared_deletions` FRESH from an item's own PLAN.md via the existing `parsePlanDocument` (`src/plan-document.cts`) — never from `BATCH.json`'s `planned_files` alone, resolving the design doc's Open Question 2. Capacity and isolation are always CALLER-SUPPLIED — the CLI layer resolves those via the existing `dispatch-capacity`/`dispatch-isolation` queries; this module never re-derives that negotiation. Source of truth: `gsd-core/bin/lib/quick-batch-dispatch.cjs` (generated from `src/quick-batch-dispatch.cts`). Tests: `tests/quick-batch-dispatch.test.cjs`, `tests/quick-batch-dispatch.property.test.cjs`.
### Quick-Batch Command Router Module
Thin CJS subcommand router for `gsd-tools quick-batch` (#3676, Phase 4 of epic #3344, ADR-1239 "Quick-batch binding"). Quick-batch is a first-party, always-on command family (like `/gsd:quick`, which has no capability-registry entry) — wired directly into `HOST_COMMAND_ROUTERS` (`gsd-core/bin/gsd-tools.cjs`), NOT the opt-in capability-registry/`activationKey` path `graphify` uses. Follows `graphify-command-router.cts`'s `routeHubCommandFamily` shape, which gives the Command Routing Hub's `makeUnknownCommand` handling for free on an unrecognized subcommand. Verbs: `create` (wraps `parseTaskListFromFile` + `createBatch`), `update` (wraps `updateBatchItems`), `resume` (wraps `resumeBatch`), `complete` (wraps `completeQuickItem`) — all thin wrappers over Quick-Batch Core Primitives Module's durable manifest read/write, reused as-is — and `effective-concurrency`/`merge-eligible`/`spawn-plan`/`verification-routing`/`merge-routing`/`cleanup-entry`/`parse-args`, thin wrappers over Quick-Batch Dispatch Core Module's pure decision functions. This router performs NO decision logic of its own beyond argument shaping (JSON parsing for array/object-shaped flags via the shared `safeJsonParse`) — every behavioral rule lives in one of the two modules it wraps. A domain `Result` failure (`{ok:false,reason}`) from either wrapped module is routed through `error()`, never silently printed as a success-shaped payload. Test seam: `_quickBatch`/`_quickBatchDispatch` injectable mocks, same `_`-prefix convention `graphify-command-router.cts` established. Source of truth: `gsd-core/bin/lib/quick-batch-command-router.cjs` (generated from `src/quick-batch-command-router.cts`). Tests: `tests/quick-batch-command-router.test.cjs`.
### Runtime Identity Module
Module owning this package's runtime identity surface and the resolver preference that keeps a shipped workflow off a foreign handler (#3146). **Domain term: _colliding bin_** — a binary name published by more than one package with different semantics behind it; here `gsd-tools`, published by both this package and the predecessor `get-shit-done-cc` (verified against 1.42.3: `bin.gsd-tools → bin/gsd-sdk.js`), whose `phases.clear` **deletes** where this package's **archives**, both printing success-shaped output against a gitignored `.planning/` (#3129). **The fix is resolution, not detection.** `_runtime-launcher.snippet.sh`'s PATH branch resolves **`gsd_run`** — published only by this package, and self-locating via its own symlink chain to the `gsd-tools.cjs` beside it — instead of the colliding `gsd-tools`, so a foreign handler is unreachable from PATH; when no `gsd_run` is reachable the resolver fails closed through its remaining path-based branches rather than falling back to an arbitrary `gsd-tools`. `unset -f gsd_run` leading that branch is load-bearing: on a second source of the preamble `command -v gsd_run` finds the shell FUNCTION and returns the bare string `gsd_run`, which would otherwise define the function in terms of itself. An `[ -x ]` guard was tried here instead and REMOVED — it rejected the bare name, fell through every branch, and reached the resolver's `exit 1`, which kills a SOURCED caller's shell. **Resolution is not the whole fix (#3841).** The path-based branches — a project-local install, a runtime config directory — trust their configured location and have no structural guarantee, so the preamble ASSERTS identity once after resolving and before any verb runs: it probes `runtime-identity --raw` and matches **anchored at BOTH ends** of the compact payload — the `IDENTITY_RAW_PREFIX` opener, then anything, then a literal `}` — because an unanchored substring match verifies the decoy `{"packageName":"get-shit-done-cc","note":"@opengsd/gsd-core"}`, and an opener-only match verifies a TRUNCATED payload. Closing on `}` is safe for any future additive field: a JSON object's own brace is always the last character, whatever the last value's type. **The trailing `}` is also load-bearing for a reason unrelated to security, and removing it turns a distant test red.** The preamble is inlined into 112 shipped files, and several downstream guards balance braces over RAW TEXT with no awareness of shell quoting — `tests/new-project-mvp-prompt.test.cjs`'s #3784 brace guard scans `new-project.md` plus `new-project/steps/`, which carry one preamble copy EACH, so an off-by-one snippet reports a combined net depth of 2 in a test naming neither the launcher nor this issue (verified: that is exactly how #3841 first went red). `tests/runtime-launcher-parity.test.cjs` (F0) now pins brace balance at the snippet so the next edit fails on the file it broke. **Domain term: _identity status_** — the two-valued `GSD_IDENTITY_STATUS` (`ok`/`unverified`, frozen as `IDENTITY_STATUS`, bridged from the five-way reason by `statusForVerdict`) the preamble exports so the gate is asserted on a VALUE, never on its warning prose. The rollout is warn-then-fail: `unverified` prints one line and continues, because `no_identity_verb` cannot tell a foreign package from an `@opengsd/gsd-core` older than the verb, and at rollout the old-version case is the common one. **The byte budget was the blocker, and folding the resolver is what cleared it:** the preamble is inlined into 113 shipped files, several of which sat within single-digit bytes of frozen ceilings (`agents/gsd-verifier.md` had 16 bytes; `gsd-executor.md` 33; `execute-phase.md` 234), and a first attempt broke five of them. Collapsing the twenty near-identical `elif [ -f … ]` arms into one candidate-list helper (`_gsd_at`) buys far more than the assertion costs — net **1,876 bytes SMALLER** per inlined file. Editing this preamble is still a ceiling hazard; measure before adding. The module exports the identity surface backing the `runtime-identity` verb: `classifyIdentityProbe` (pure, total — `(stdout, exitCode, spawnFailed, timedOut)` → `ok`/`identity_mismatch`/`no_identity_verb`/`unparseable`/`probe_failed`; strict because `JSON.parse` admits `0`/`"str"`/`[]`/`null`/`true` and a truthiness test would verify `[]`), `buildIdentityPayload` over baked `package-identity.cjs` coordinates plus `readHostVersion()`, and `explainVerdict`. That verb is a manual diagnostic, not an automatic gate. Source of truth: `gsd-core/bin/lib/runtime-identity.cjs` (generated from `src/runtime-identity.cts`).
### Dispatch Policy Module
Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract.
Canonical error kind set:
- `unknown_command`
- `native_failure`
- `native_timeout`
- `fallback_failure`
- `validation_error`
- `internal_error`
### Command Definition Module
Canonical command metadata Interface powering alias, catalog, and semantics generation.
### Query Runtime Context Module
Module owning query-time context resolution for `projectDir` and `ws`, including precedence and validation policy used by query adapters.
### Native Dispatch Adapter Module
Adapter Module that satisfies native query dispatch at the Dispatch Policy seam, so policy modules consume a focused dispatch Interface instead of closure-wired call sites.
### Query CLI Output Module
Module owning projection from dispatch results/errors to CLI `{ exitCode, stdoutChunks, stderrLines }` output contract.
### STATE.md Document Module
Module owning STATE.md parse, field extraction, field replacement, status normalization, frontmatter reconstruction, and `## Current Position` section scoping (`stateCurrentPositionSlice`, #1956 — the one owner of that scope for the read path; `state.cts`'s `matchCurrentPositionSection` is a thin alias over it, and the `drift-guard phase-status` seam consumes it, so the #2956 archive-shadowing fix cannot be re-derived into a second copy — the byte-exact mutation path served by `state-transition.cts`'s `locateCurrentPosition`/`sliceCurrentPositionSection` is a deliberately separate, un-consolidated locator, #3187). `stateFieldValue` (#3187) is the single owner of the #1760 frontmatter-then-body field fallback chain, consolidating the 14 re-derivations of that ladder onto one scope-carrying (`complete`/`truncated`/`unscoped`/`unreadable`) primitive. `isRealCalendarDate` (#3696) is the single owner of "does this y/m/d exist on the calendar" — moved here from a private copy in `smart-entry.cts` so that reader and `state validate`'s S008 cannot drift into disagreeing about whether a STATE.md is usable; it enforces ADR-227's shape-AND-value rule, rejecting `2026-02-30` rather than letting `Date.parse` roll it forward to `2026-03-02`. `stateFieldContinuation` (#3696) reports the prose a wrapped single-line field leaves behind, which `stateExtractField`'s newline-excluding `(.+)` silently drops (S009); it is additive beside `stateExtractField` rather than a widening of it, because ADR-3180 §7.7 Rejected #1 pins that function as untouchable (20 direct callers, CRITICAL blast radius) and a continuation join there would apply to every field — `Status:` would swallow the line beneath it. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: `gsd-core/bin/lib/state-document.cjs`. **Commit provenance (#2573):** `state_head` records the full sha STATE.md was written against, stamped by `syncStateFrontmatter` and omitted entirely outside a git repo. `readStateHeadFreshness(cwd, stateHead)` (`src/state.cts`) is the single derivation consumed by both `validate.health` (W024) and smart-entry — it returns `{ state_head, current_commit, commits_behind, commit_stale }` with **tri-state** `commit_stale`: `null` = unknown (no stamp, no git, or a stamp that is not an ancestor of HEAD after a history rewrite), `false` = known fresh, `true` = the codebase has moved. Mirrors the graphify commit-staleness contract deliberately. It is a freshness PROXY, never a drift measurement: `rev-list` counts unrelated commits and the stamp restamps on every state write, so a low count means STATE.md was written recently, not that its contents are accurate — it must never gate. `toFiniteNumber` (#3871) is now EXPORTED as the single owner of "coerce this frontmatter scalar to a finite number, or say it is not one" — the STATE.md Transition Module's progress-ratchet consults it to decide whether a derived total is a real measurement, and a private second copy there would have diverged immediately, because frontmatter scalars arrive as STRINGS (`"0"`, not `0`) and a `=== 0` test is wrong at both call sites.
### STATE.md Transition Module
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` (phase.cts's former direct caller has since been migrated away). `syncAndPreserveStateMd` (`state.cts`, #3469) is the single composition of `syncStateFrontmatter` + `applyPostSyncPreservation`; `readModifyWriteStateMd` and `cmdPhaseComplete` both **call** it rather than assembling the two steps, because assembling them at a call site is a re-derivation even when every step calls an owner. **Exactly two direct `writeStateMd` callers remain, and both are SANCTIONED PERMANENT exceptions under ADR-3408 §8.3 (as amended): `cmdStateSync` and the `REGENERATE_STATE` remedy (`health-diagnostic.cts`).** Neither is debt — `state sync` exists to re-derive frontmatter *from* the body (#905), and `REGENERATE_STATE` is a factory reset; preservation on either would re-lock precisely what the command was invoked to replace. `cmdMilestoneComplete` was the third and is now routed through the composition (#3469). Phase 1's whole-repo drift guard, not this line, is the authoritative count. *(Location correction: this entry previously placed the factory-reset primitive at `verify.cts:1925`. It moved to `health-diagnostic.cts` when `cmdValidateHealth` migrated onto the rule table (#3309); `verify.cts` now contains no `writeStateMd` call. The design intent was unchanged — only the address was stale.)* Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`). `state_head` (#2573) is classified `{ source: 'free', preservation: 'derive' }` — an ambient git read recomputed on every write, like `last_updated`; never preserved, because a stale stamp would claim STATE.md was written against a commit it wasn't. **Phase 4 (#3471):** `syncStateFrontmatter`'s six empty-only guards (D1) are now GATED — active only for the §8.3 sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`), which have no preservation executor downstream and for which body-beats-frontmatter is the deliberate contract; OFF on the write seam, where `applyStatePreservation` owns the empty case via the exported `applyPreserveWhenUnchanged` executor. `reconcileReportedFields` (`state.cts`, §8.4/D4) is the single owner of reconciling a command's reported `updated`/`failed` field array against what was actually persisted, closing both #3351's (reported-but-discarded) and #3345's (persisted-but-unreported) directions; used by seven commands rather than seven re-derivations. `cmdStateJson` (§8.5/D3) no longer carries a private copy of the empty-only guards; it routes through `applyPreserveWhenUnchanged` directly, scoped to the same six fields. This entry has now needed correcting three times inside this one epic; Phase 1's whole-repo drift guard, not this line, remains the authoritative count. **ADR-3473 §8.7 (#3872) — the transaction diff.** `reconcileReportedFields` no longer compares the transform's own output against persisted bytes, nor filters `divergedFields` by policy class. It compares **persisted against the pre-write state the transaction already holds**, surfaced to the command through the same caller-allocates out-param idiom `divergedFields` established (and which `readModifyWriteStateMd`'s hand-enumerated option forwarding must list, or the field is silently dropped). Both prior directions fall out of that one comparison: a field the transform reported but the pipeline discarded is persisted-equals-snapshot and drops out (#3351), and a field nobody reported but the write moved is different and appears (#3345's `total_phases` 7→4, #3818's `current_phase` 203→204). The classification filter is **deleted, not relocated** — no `getFieldClassification` test survives in the reporting path. Reporting is at **dotted-leaf** granularity, enumerated from the `progress.*` rows `FIELD_CLASSIFICATION` already declares rather than by walking user data to arbitrary depth; that closes a live drop, because `plannedPhaseCore` already pushed `progress.total_plans` and a flat `hasOwnProperty` could never resolve it against nested frontmatter (`Current Position` was lost the same way). The one exclusion is `last_updated`, and it is by **provenance, not classification**: it is the only field measured to change on every write regardless of content. `state_head` was measured NOT to qualify — recomputed every write, but its value moves only when git HEAD moved. Without that single exclusion, `state.patch`'s success signal (`updated.length > 0`, `state.cts`) would be permanently true and a fully-failed patch would report success. **ADR-3473 §8.6 (#3871) — the state transaction.** `StateTransaction` is the Module's pre-write policy value, constructed only by `openStateTransaction` (preservation applies) or `rebuildStateTransaction` (it does not); both carry a MANDATORY `snapshot`, and an absent one is a **construction failure** (`STATE_TRANSACTION_SNAPSHOT_REQUIRED`), never a runtime no-op. This replaces the `preFm`/`preFmSnapshot` pair, which were the same `extractFrontmatter` call with one copy nulled on `resync` — a policy flag encoded as a missing input, which is why a declared `preserve-always` row silently skipped on the default write path (#3756). An EMPTY snapshot (`{}`) stays legal: that is the honest snapshot of a document with no parseable frontmatter, and `/gsd-health --repair` runs precisely on such documents. `rebuildStateTransaction` is the typed expression of ADR-3408 §8.3's closed exception list — `cmdStateSync` (#905) and `REGENERATE_STATE` — so `writeStateMd` takes a `rebuild` transaction and rejects anything else, and the two `sanctioned-permanent` entries the write-path drift guard used to ratchet as strings are retired with their baseline file. Within `applyPreserveAlways`, an all-zero or absent set of derived `progress` TOTALS is an **unmeasured scan, not a measurement** (the convention #3233 established, and why `computeProgressPercent` already returns `null` on an empty denominator), so the curated block stands rather than being overwritten with zeros; `completed_*` being zero is normal and does not decide it, and a measured scan still corrects totals downward (#1446/#2440). The rule carries a second, non-negotiable condition: it does **not** fire when the caller NAMED a progress field, because `preserve-always` has always meant "never overwrite unless the caller explicitly names this field" and `state update Progress` exists precisely to re-derive the block from the body just rewritten. `explicitProgressField` carries that and is **derived, never hand-set** — it comes from `shouldResyncStateProgress(fields)` (true iff the field set contains `Progress`, `Total Plans in Phase` or `Total Phases`), so it cannot drift from what the caller actually asked for. Omitting it silently discarded an explicitly-requested resync (#3242 / #1972), caught by the remote matrix, not by review. `getPreserveWhenUnchangedFields()` projects the `preserve-when-unchanged` rows out of `FIELD_CLASSIFICATION` so `cmdStateJson` consults the declaration instead of the hand-maintained parallel list that had drifted from it (#3836). **ADR-3408 §8.3 body-progress reconciliation (#4213):** `applyPostSyncPreservation` now closes by reconciling the body `Progress:` line against the persisted frontmatter `progress.percent` through `stateReplaceProgressPercent` (STATE.md Transition Module) — the #4213 fix: 13 of 15 `state.*` verbs rewrote `progress.percent` without touching the body bar, so the two surfaces silently diverged. The helper owns the #2177 priority (bold `**Progress:**` anywhere beats a plain `^Progress:` line, so a free-text line starting with `Progress:` cannot capture the rewrite) and clamps its bar through the completion-ratio kernel, so a hand-edited out-of-range persisted percent renders `[██████████] 100%` instead of throwing. It runs on every write through this seam, preserved-branch or not. Three named consumers keep the surfaces equal: this reconciliation, `cmdStateUpdateProgress`, and `syncCore`'s progress intent — `cmdStateSync` is the §8.3 sanctioned exception whose body-wins contract means it never reaches preservation, and its correctness comes from the `syncCore` call. A future writer that builds STATE.md content without one of those three reintroduces the divergence class.
### STATE.md Field Schema Module
The one declaration (ADR-3473 §8.8, #3873) for "which STATE.md keys exist and what they carry", replacing three hand-maintained tables that were already observed to disagree: `FIELD_CLASSIFICATION` and `FRONTMATTER_BODY_SOURCE` (STATE.md Transition Module) and `FRONTMATTER_KEY_TO_BODY_LABEL` (STATE.md Document Module's `bodyLabelFor`, `state.cts`). One frozen, null-prototype `STATE_FIELD_SCHEMA` row per key carries `type`, `cardinality`, `source`, `preservation`, `guard`/`mergeStrategy` (ADR-3408's closed vocabularies, whose type declarations moved here), `bodySource`/`bodyLabel`, `acceptedShapes` (declared value shapes a hand-written parser accepts — e.g. `current_plan`'s `N` / `N of M`, #3784 — never a predicate; Greenspun's Tenth Rule still applies), and `emitted` (mirrors `buildStateFrontmatter`'s null-guards). The three original tables are now PROJECTIONS derived from this schema at module load, byte-identical in shape/key-order/frozen-and-null-prototype-ness to what they were before #3873 — every existing consumer (the preservation dispatch loop, `getFieldClassification`, `getPreserveWhenUnchangedFields`, `bodyLabelFor`, #3872's `declaredLeavesOf`) is unaffected. **The `last_activity` disagreement is resolved by declaration, not by picking the table that "looks right":** it carries a `bodySource` (it IS body-derived) but deliberately no `bodyLabel`, matching what ships today — its `preservation` is `derive`, so it can never reach `bodyLabelFor`'s `STATE_BODY_LABEL_UNWIRED_ROW` throw, pinned by `tests/state.test.cjs`'s `lastActivityLabelResolutionMatchesShippedBehavior`. Leaf module: imports from neither `state-transition.cts` nor `state.cts` (both import it), avoiding the CJS require-cycle `src/health-diagnostic-types.cts` was split out to break. `scripts/lint-state-field-drift.cjs` is UNCHANGED and retained — it guards the #3187 STATE.md field-extraction fallback-chain re-derivation, an orthogonal concern this schema does not make unrepresentable (§8.8's own claim that the guard is deleted here was verified false). Source of truth: `gsd-core/bin/lib/state-md-schema.cjs` (generated from `src/state-md-schema.cts`).
### STATE.md Status Lifecycle (ADR-2207)
The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → `<version> milestone complete` (terminal, written only by the milestone-close verb `milestoneCompleteCore`) → `Awaiting next milestone` (archived). Phase-completion verbs write `All phases complete` on the last phase — never `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). `normalizeStateStatus` maps any status containing "complete" → `completed`, so consumers using the normalized projection (workstream inventory's `status` field, statusline) recognize `All phases complete` without code changes. Note: `isCompletedInventory` (workstream-inventory-builder.cts) intentionally checks only for the terminal `\bmilestone\s+complete\b` / `\barchived\b` — `All phases complete` returns `false` (intermediate, not terminal).
### Query Execution Policy Module
Module owning query transport routing policy projection (`preferNative`, fallback policy, workstream subprocess forcing) at execution seam.
### Query Subprocess Adapter Module
Adapter Module owning subprocess execution contract for query commands (JSON/raw invocation, `@file:` indirection parsing, timeout/exit error projection).
### Query Command Resolution Module
Canonical command normalization and resolution Interface (`query-command-resolution-strategy`) used by internal query/transport paths after dead-wrapper convergence.
### Command Topology Module
Module owning command resolution, policy projection (`mutation`, `output_mode`), unknown-command diagnosis, and handler Adapter binding at one seam for query dispatch.
### Init Command Module
Module owning the `init.*` family of query handlers that compose atomic queries into the flat JSON bundles consumed by init workflows (`/gsd-execute-phase`, `/gsd-plan-phase`, `/gsd-verify-work`, `/gsd-new-project`, `/gsd-onboard`, `/gsd-manager`, `/gsd-progress`, `/gsd-resume`, etc.). Source of truth: `src/init.cts` and the compiled `gsd-core/bin/lib/init.cjs`; onboarding routing readiness lives in `src/onboard-projection.cts`. The basic handlers (plus `withProjectRoot` project-identity injection) and the heavyweight handlers (`initNewProject`, `initOnboard`, `initProgress`, `initManager`) return `{ data: <flat JSON> }`. Test seams: `tests/init.test.cjs`, `tests/onboard-command.test.cjs`, and `tests/init-manager.test.cjs` (cover withProjectRoot precedence, onboarding projection/rendering, progress/manager precedence regression #2674, workstream scoping regression #3196, and cross-milestone dependency regression #2267). (The SDK `handlers/init/*.ts` sources and the `init*.test.ts` seams were retired with the SDK package per ADR-0174.)
### Command Routing Hub
Single dispatch seam (`gsd-core/bin/lib/command-routing-hub.cjs`) that centralizes CJS routing, the no-throw pure-result contract, typed error variants, and dispatch-event emission for all command family adapters. Interface: `createHub({ cjsRegistry, manifest, logger }) → hub`; `hub.dispatch({ family, subcommand, args, cwd, raw, parentTraceId? }) → Result` where `Result = { ok: true, data } | { ok: false, kind, ...typedPayload }` and `kind ∈ { UnknownCommand, InvalidArgs, HandlerRefusal, HandlerFailure }`. The `InvalidArgs` variant carries an optional `exitReason?: string` field (amendment #1642 / #1644 Phase 1) holding the `ERROR_REASON` enum value, separate from `reason` (the explanation text); the `makeInvalidArgs(arg, reason, exitReason?)` factory omits the field when the third arg is absent, undefined, or empty — preserving the strict-keys invariant tested at `tests/command-routing-hub.test.cjs:444`. The Hub is single-runtime (no mode selection, no sdkLoader), never prints, never exits, never throws. Adapters call `createHub`, dispatch, then translate the pure Result to `output()`/`error()` calls; when an `InvalidArgs` Result carries `exitReason`, the adapter passes it as the second arg to `error(message, exitReason)` so the JSON-error envelope (`GSD_JSON_ERRORS=1`) preserves the typed reason. Source: `gsd-core/bin/lib/command-routing-hub.cjs`; ADR: `docs/adr/0174-retire-gsd-sdk-package-boundary.md` (§5 amended #1642).
### Command Dispatch Completion (ADR-2346, epic #2345)
Decision to dissolve `runCommand`'s 73-case ~2,338-line switch (the repo's #1 PageRank / #1 Tarjan-bridge / #1 complexity symbol) into a **two-layer dispatch**: families via the `commandFamilies` registry (ADR-959 mechanism, completed) and single-purpose **leaf verbs** via a dispatch table filling the prepared `_dispatchNonFamily` seam (today a dead shim returning `false`); `runCommand` collapses to a ~15-line `try registry → try leaf table → unknown`. Family/leaf rule: promote a cluster to a family iff ≥3 related subcommands + shared backing module + shared parse/return shape; lone verbs or pairs stay leaves. Nine families result (`state`/`phase`/`init`/`roadmap`/`validate`/`verify`/`capability` + promoted `config`/`research`/`resolve`/`git`); `worktree`+`workstream` stay leaves; ~40 remaining verbs rehome into ~4 themed leaf modules. A shared `parseFamilyArgs` (in `cjs-command-router-adapter.cts` beside `routeHubCommandFamily`) deletes the 4+ duplicated inline arg-parsers. The 706-line `capability` arm extracts to a thin `capability-command-router` (intel-shaped) + `capability-cli.cts` (CLI wiring), consolidating duplicated probes (`capHostVersion`→`readHostVersion()`, `capReadStrict`+drift-guard → one `readStrictKnownRegistries`). Behavior-preserving — each cutover proven equivalent by extending `tests/audit-command-cutover.test.cjs`'s 5-category template. Phased: P1 `parseFamilyArgs`+Tier-1 cutovers, P2 capability extraction, P3 new families, P4 leaf table + collapse. Source: `docs/adr/2346-command-dispatch-completion.md`; graduates ADR-959 `Proposed → Accepted`. _Avoid_: "the dispatch service" (when you mean the seam).
### Runtime Source Layout Module
Single-runtime seam layout for this repository after SDK retirement. Runtime execution paths live under `gsd-core/bin/lib/` and are grouped by seam concern (dispatch, manifest, handlers, runtime, observability, installer). ADR-0174 preserves the seam vocabulary and defines the canonical long-term shape as a seam-aligned TypeScript `src/` tree (`src/dispatch/`, `src/handlers/`, `src/errors/`, `src/manifest/`, `src/config/`, `src/state/`, `src/workstream/`, `src/runtime/`, `src/cli/`, `src/observability/`) compiled to CJS.
### Runtime Launcher Module
Canonical space-safe shell preamble (`gsd_run`) used by every workflow bash block to invoke the GSD runtime CLI. Resolves `gsd-core/bin/gsd-tools.cjs` via `node` when present, falls back to a `gsd-tools` binary on PATH, else errors. Single source of truth: `gsd-core/workflows/_runtime-launcher.snippet.sh`; propagated by `scripts/sync-runtime-launcher.cjs`; enforced by `tests/runtime-launcher-parity.test.cjs`. Replaced the retired unquoted `$GSD_SDK` variable (#373). `gsd_run` is also shipped as a standalone executable (`gsd-core/bin/gsd_run`) via the npm `bin` field; on runtimes that run each fenced bash block in a fresh shell (e.g. Claude Code), the per-file preamble appends the bin directory to `CLAUDE_ENV_FILE` so `gsd_run` resolves from PATH in later blocks — the inline function definition remains the fallback for all other runtimes.
### Dispatch Observability Module
Module owning dispatch-event creation, redaction, and logger behavior for the Command Routing Hub. Core files: `gsd-core/bin/lib/observability/event.cjs`, `gsd-core/bin/lib/observability/logger.cjs`, `gsd-core/bin/lib/observability/redaction.cjs`. Contract: silent on success by default, structured JSON to stderr on error, and opt-in audit trail at `.planning/.gsd-trace.jsonl` via `GSD_AUDIT=1` or config (`audit.enabled`). Each dispatch carries a `traceId`; composed dispatches set `parentTraceId` for correlation.
### Query Pre-Project Config Policy Module
Module policy that defines query-time behavior when `.planning/config.json` is absent: use built-in defaults for parity-sensitive query Interfaces, and emit parity-aligned empty model ids for pre-project model resolution surfaces.
### Configuration Module
Module owning legacy-key normalization, defaults merge, and explicit on-disk migration for `.planning/config.json`. Interface: `normalizeLegacyKeys(parsed) → { parsed, normalizations[], skipped[] }` (idempotent, pure, returns the list of normalizations applied plus the list of migrations declined), `isConfigSection(value) → boolean` (is a value usable as a nested config section — a non-null, non-array object), `mergeDefaults(parsed) → MergedConfig` (deep-merge of parsed config over canonical defaults), `migrateOnDisk(cwd) → MigrationReport` (explicit, opt-in, called by the installer and by `gsd-tools migrate-config`). Invariants: legacy top-level keys (`branching_strategy`, `sub_repos`, `multiRepo`, `depth`) are normalized into their canonical nested locations in the returned value; **a destination section that is present but is NOT an object blocks its own migration rather than being overwritten (#3760)** — spreading such a value expands a string into character keys (`{...'main'}` is `{0:'m',1:'a',2:'i',3:'n'}`) and collapses a number or boolean to `{}`, and because a reported normalization is what marks a config dirty, that shape was then written to the user's `config.json`; the refusal leaves the section, the legacy key, and the file byte-identical and records a `skipped` entry in-band — the nested-section analog of the top-level ADR-227 shape check `_readConfigFile` already performs. **The ADR-1411 out-of-band `UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT` diagnostic is emitted by this module's CALLERS — `cmdMigrateConfig` (Config CRUD) and `loadConfigResolved` (Config Loader) — never by this module**, because `configuration.cjs` must load from an install layout containing only itself plus `bin/shared/*.manifest.json` (the #3571 contract, pinned by `tests/install.test.cjs` "co-located bin/shared manifests let configuration.cjs load without sdk/shared"); a sibling `require` the installer does not co-locate fails at load time with `MODULE_NOT_FOUND`. Reporting in-band via `skipped[]` is what keeps the module both pure AND dependency-free; defaults come from the shared `gsd-core/bin/shared/config-defaults.manifest.json`; schema (`VALID_CONFIG_KEYS`, `RUNTIME_STATE_KEYS`, `DYNAMIC_KEY_PATTERNS`) comes from `gsd-core/bin/shared/config-schema.manifest.json`. Note: `loadConfig` (project config read + merge) was extracted to the Config Loader Module (`config-loader.cjs`) per ADR-857 phase 2e (#885); `configuration.cjs` now provides only the pure normalization and defaults primitives that `config-loader.cjs` depends on. Source of truth: `gsd-core/bin/lib/configuration.cjs`, consumed via `bin/lib/config-loader.cjs` and `bin/lib/config-schema.cjs`. Eliminates the recurring #3523-class drift bug structurally.
### Planning Scope Module
Leaf module owning the frozen `SCOPE` discriminator (`COMPLETE` / `TRUNCATED` / `UNSCOPED` / `UNREADABLE`) that every consolidated `.planning/` semantic derivation returns alongside its payload, per ADR-3180 Decision 2. It exists to make one distinction representable: `COMPLETE` with zero items is a REAL answer (a phase genuinely has no plans; a milestone genuinely has no phases yet), while the other three with zero items are NON-answers — the derivation could not see all of its input. Before it, those two cases were output-identical, which is the failure class epic #3180 removes: a truncated milestone window returned `phase_count: 0` with no error, indistinguishable from a freshly-declared milestone. It is a frozen enum rather than a message string because `CONTRIBUTING.md` bans raw-text matching on outputs and requires a typed IR, so callers branch on `result.scope === SCOPE.TRUNCATED`. Pure and import-free — the bottom of the dependency graph, so any consumer can depend on it without a cycle (mirrors the Phase Id Module's leaf position). Source of truth: `gsd-core/bin/lib/planning-scope.cjs` (generated from `src/planning-scope.cts`). The contract is PROVISIONAL: #3183 is its first real implementation, and ADR-3180 requires the ADR be amended before Phase 2 rather than the contract worked around, if it does not fit.
### Pattern Module
Leaf module owning the construction of a regex from a **runtime value**, per ADR-3212 §1 (epic #3212 Phase 1, #3412). Exposes `escapeRegex(value) → string` and `literalPattern(value, flags?) → RegExp`. `escapeRegex` **delegates to the built-in `RegExp.escape`** (TC39 Stage 4, ES2026, Node 24+) — it is deliberately NOT an implementation, which is the whole point: the ~39 hand-rolled copies it replaces existed because every author re-derived the metacharacter set, and TC39 standardized the primitive precisely because userland versions "miss edge cases." `escapeRegex` is the primary export (the large majority of call sites build a regex *source string* and interpolate it into a larger pattern); `literalPattern` is the minority convenience for the `new RegExp(escapeRegex(v))` shape. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Planning Scope and Phase Id modules' position). **Behavioral note, measured not assumed:** `RegExp.escape` is a *superset* escaper and produces different pattern SOURCE TEXT than the hand-rolled class did — it hex-escapes the leading character of nearly every string (`"abc"` → `"\x61bc"`), plus `-`, space, `/`, and control chars. It is **match-equivalent** (verified by a seeded `fast-check` property test against the deleted implementation as oracle, plus a fixed corpus), so no consumer's matching behavior changes; but anything asserting on pattern text rather than match results does. It also **fixes a latent bug as a side effect**: a hyphen-bearing value interpolated into a character class previously formed a real range (`[a-z]` built from an escaped `"a-z"` matched `"m"`), and no longer does. Enforced by `eslint-rules/no-adhoc-regex-escape.cjs` (fires on the `.replace(<metachar-class>, '\\$&')` shape anywhere outside this module, matching on shape rather than exact bytes, and on `new RegExp` built from an unescaped runtime value; exempts reviewed pattern-fragment constants such as `PHASE_NUMBER_TOKEN_SOURCE` by structural provenance — a module-scope const with a static initializer — not by name alone) plus `scripts/lint-no-adhoc-regex-escape.cjs`, a whole-tree companion covering directories ESLint's globs miss. Requires `engines.node >= 24.0.0`; a seam test asserts `typeof RegExp.escape === 'function'` so the floor and the capability cannot silently diverge. Source of truth: `gsd-core/bin/lib/pattern.cjs` (generated from `src/pattern.cts`). Design: `.gsd/phase/chore-3412-pattern-seam/40-design.md`.
### Text Lines Module
Leaf module owning `\r?\n` line-terminator splitting and CRLF normalization, per ADR-3212 §3 (epic #3212 Phase 2, #3413). Exposes `splitLines(content) → string[]` (splits on `\r\n` or `\n`; a lone `\r` is not a delimiter), `normalizeEol(content) → string` (strips every `\r`, matching the four `scripts/gen-*.cjs` `--check` copies it replaces), `detectEol(content) → '\n' | '\r\n'` (dominant terminator, `'\r\n'`-default on a tie or no terminator), and `joinLines(lines, eol?) → string` (inverse of `splitLines`; round-trips byte-for-byte with `detectEol`). Closes #3360: `parseMustHavesBlock` (`src/frontmatter.cts`) matched `^(\s*)must_haves:\s*$` and a sibling block-header regex against the WHOLE multi-line YAML string under `/m`, and `\r` is its own ECMA-262 LineTerminator — a greedy `\s*` anchored on `^` could cross a CRLF boundary and inflate the captured indent by one character, tripping the nesting guard and silently returning `[]` for every `must_haves` block on a CRLF plan file. The fix reroutes both lookups through split-then-match (`splitLines` first, then match per already-split line), which cannot straddle the delimiter that produced it. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Pattern Module's position). Enforced going forward by the widened `eslint-rules/no-crlf-fragile-split.cjs` (now also scanned against `src/**/*.cts`, not only `tests/`). Source of truth: `gsd-core/bin/lib/text-lines.cjs` (generated from `src/text-lines.cts`). Design: `.gsd/phase/chore-3413-text-lines-seam/40-design.md`.
### Frontmatter Module
Module owning YAML frontmatter parsing, serialization and CRUD commands. As of ADR-3473 §8.1 (#3881) the read path is no longer a hand-rolled line scanner — `parseYamlRegion`, `escapeDoubleQuoted`, `unescapeDoubleQuoted` and `parseQuotedScalar` are **deleted, not patched** (§8.1's words), and `extractFrontmatter` parses through the vendored js-yaml (`gsd-core/bin/lib/vendor/js-yaml.cjs`, type twin `src/vendor/js-yaml.d.cts`) under `FAILSAFE_SCHEMA` + `json: true`. `FAILSAFE_SCHEMA` resolves only `!!str`/`!!seq`/`!!map`, so every scalar still comes back a string (today's contract, unchanged for callers), and `json: true` makes a duplicate key overwrite rather than throw — the documented last-wins invariant `tests/fixtures/adversarial/frontmatter/duplicate-keys.md` pins. What js-yaml does not do is layered on top in this module: anchors, aliases and merge keys are refused outright (a raw-text pre-scan, `refuseAnchorsAndAliases`) because `FAILSAFE_SCHEMA` still resolves core YAML anchor/alias mechanics — not a tag-resolution concern a schema choice can disable — and `.planning/` documents are untrusted, user-authored input a hostile few-line alias fan-out could otherwise expand by orders of magnitude in milliseconds; the #3257 full-line-comment channel and the #1882 truncation probe are likewise preserved unchanged. A closed region that js-yaml itself cannot parse (malformed YAML, or a refused anchor/alias/merge key) returns `{}` carrying an exported `FRONTMATTER_UNPARSEABLE` Symbol rather than a bare, indistinguishable `{}` — Symbol-keyed so it is invisible to `Object.keys`/`JSON.stringify`/`for-in` and the ~70 call sites that never inspect it are unaffected, while the ~8 `hasFrontmatter = Object.keys(...).length > 0` call sites can consult it to tell "genuinely empty" apart from "could not parse" and avoid reassembling a document without its (unparsed but still present) frontmatter block. **All 8 sites are wired** — 7 in `src/state-transition.cts` (`beginPhaseCore`, `advancePlanCore`, complete-phase, planned-phase, milestone-complete, `patchCore`, `updateCore`) and 1 in `src/state.cts` — verified against a STATE.md carrying git merge-conflict markers, which previously lost its frontmatter block entirely on the next write and now round-trips intact. Scope note: the full CLI write path (`readModifyWriteStateMd` → `syncAndPreserveStateMd` → `syncStateFrontmatter`) re-derives and rebuilds the frontmatter block on every write regardless of what the transform returned, so the marker's observable effect is at the transform layer and for any caller bypassing that rebuild — it does not change on-disk output for today's `state update` / `state patch`. `extractFrontmatterBlock` (Phase Estimation Module) is a deliberate sibling, not a duplicate: it stays hand-rolled because it returns numeric-looking values as numbers rather than strings, a type contract `FAILSAFE_SCHEMA` cannot express. Source of truth: `gsd-core/bin/lib/frontmatter.cjs` (generated from `src/frontmatter.cts`).
### Token Scanner Module
Leaf module generalizing the proven `hooks/lib/git-cmd.js` token-walk (#3129) into a shared primitive for stateful grammars, per ADR-3212 §4 (epic #3212 Phase 3, #3414). Exposes `tokenizeShellLike(cmd) → string[]` (quote-aware shell tokenizer — single/double-quoted spans as one token, no escape or brace/variable expansion, byte-identical port of `git-cmd.js`'s original `tokenize()`) and `indentWidth(line) → number` (leading-whitespace column count, tabs counted as one column each, no tab-width policy introduced). `hooks/lib/git-cmd.js` migrates its `tokenize()`/`isGitSubcommand()` onto `tokenizeShellLike` with zero behavior change (ADR §6 extend-never-mutate; parity-asserted against every existing fixture) and gains `extractBranchArgument(cmd) → string | null` (`git checkout -b <name>` / `git branch <name>`) — a new capability exercising the seam on the domain the ADR names, not a migration of existing duplicated logic (none existed). Closes #3169: `src/decisions.cts`'s decision-bullet parser could not distinguish a cross-reference bullet NESTED under an already-open decision from a fresh, malformed top-level declaration attempt — both have identical shape under any bullet-*content* classifier (an earlier design using bold-run-content classification was disproven against the repo's own existing FIX-B fixtures before being adopted, per the phase's design doc). `indentWidth` supplies the structural signal a per-line regex cannot see: a bulleted line indented deeper than the currently-open decision's own bullet is that decision's elaboration, folded into its text like a continuation line, never tested against the declaration/parse-miss regexes. A bullet at the same-or-shallower indent is unaffected. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Pattern and Text Lines modules' position). Hook-staging note: `hooks/` scripts are staged as standalone files at install time, so `git-cmd.js` requires the BUILT `gsd-core/bin/lib/token-scanner.cjs` artifact (matching Phase 2's `scripts/gen-*.cjs` consolidation), not a sibling `hooks/lib/` file. Source of truth: `gsd-core/bin/lib/token-scanner.cjs` (generated from `src/token-scanner.cts`). Design: `.gsd/phase/chore-3414-tokenizer-first-seam/40-design.md`.
### 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`.
### 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. As of ADR-3646 (#3970) this module also owns the `tracker-id` task attribute: parsed verbatim off the `<task>` block into `PlanTask.trackerId` (string or `null`), never split into prefix/id here — that split is the Task Content Resolution Module's job. Source of truth: `gsd-core/bin/lib/plan-document.cjs` (generated from `src/plan-document.cts`).
### Task Content Resolution Module
Given a task's `tracker-id` attribute (Plan Document Module) and the set of installed capabilities' `taskContentResolver` declarations, resolves that task's content — `action`/`verify`/`acceptanceCriteria`/`readFirst`/`done` — from the matching external tracker via one bounded subprocess call (ADR-3646 Phase 1, #3970), or reports that no resolution applies. Pure/impure split: `splitTrackerId` (first-`:`-only split, colons after the first stay in the id verbatim), `findResolver` (matches a capability's `trackerPrefix`, returns `null`/one match/`'ambiguous'`, never silently picks one), and `buildInvocation` (expands `invoke.args`' `{{id}}` placeholder) are pure and total, never throwing even on hostile third-party-shaped capability input; `resolveTaskContent` is the one impure boundary, spawning through an injectable `execFn` (`spawnSync` with a bounded `timeout`) so tests never spawn a real process or wait a real timeout. **Four non-throwing outcomes** (`TASK_CONTENT_RESULT`: `not-applicable`, `no-resolver`, `resolved`, `empty`) and **HARD-HALT on four throwing outcomes** (`ResolverAmbiguousError`, `ResolverFailedError`, `ResolverTimeoutError`, `ResolverMalformedOutputError`) — ADR-3646 Decision 4: an ambiguous resolver match, a non-zero resolver exit, a timeout, or malformed resolver stdout must never degrade to a silently-empty or silently-picked result. Wired at `task resolve-content --plan <path> --task-id <tracker-id> --raw` (`task-command-router.cts`'s `routeResolveContent`), which turns each of the four throws into the CLI's own non-zero exit rather than swallowing them into a `{resolved: false}` JSON answer; capabilities are loaded through the merged first-party + validated-installed-overlay registry (ADR-1244 D2) so a third-party capability's resolver is honored. Source of truth: `gsd-core/bin/lib/task-content-resolution.cjs` (generated from `src/task-content-resolution.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`.
### State Contract Module
Module owning the **machine-readable state contract v1** published to `.planning/state.json` at every step boundary (#3227), so an external reader (GSD Workbench, an editor extension, a dashboard) binds to a versioned contract instead of parsing STATE.md/ROADMAP.md heuristically. `buildStateContract(cwd, deps?) → snapshot` is PURE (writes nothing); `publishStateContract(cwd, deps?) → {published, reason}` writes it and **never throws for any input**. Wire shape, keys always present and in a pinned order: `contract` (semver `1.0.0`; additive-only under 1.x — the consumer's version gate), `flavor` (`core`), `milestone` (`"v1.1 — Hardening"`, or the bare version when the roadmap carries no name, or `null`), `phases[]` (`{number, name, status}`; `number` is a STRING because `01` and `2.1` are both real ids), `next` (`{command, label, reason}` or `null`), `updated_at`. Two frozen enums are the wire vocabulary: `PHASE_STATUS` (`complete|in_progress|pending`) and `PUBLISH_REASON` (`published|no_planning_dir|write_failed`). **Composes, never re-derives**: phase rows from `locateProgressTable` (Phase Lifecycle Module — the SAME `## Progress` locator `deriveProgressFromRoadmap` uses, extracted in this change precisely so `state.json` cannot disagree with GSD's own progress counters), milestone identity from `getMilestoneInfo` (Roadmap Parser Module), the recommended action from `classifyProject` (Smart Entry Module) so `next` equals smart-entry's routing BY CONSTRUCTION rather than by a second copy of its table, paths from `planningPaths` (workstream-aware), and I/O through `platformReadSync`/`platformWriteSync` — the latter is **already atomic** (sibling tmp + `retryRenameSync`), so this module adds no sixth atomic-write helper and takes **no `withPlanningLock`** (that lock is non-re-entrant and several phase commands already hold it). **Every owner is required LAZILY inside the function body**, never at module top level: `state.cts` imports this module and `smart-entry.cts` destructures off `state.cjs` at load time, so a static import would close the cycle `state → state-contract → smart-entry → state` and bind `undefined`. **Best-effort by contract**: a missing ROADMAP.md, an unreadable document, or an unwritable target degrades to a typed `reason` and can never change the parent command's exit code or stdout; a directory with no `.planning/` is left untouched rather than having one created for it. Publishing is wired at the 11 boundary commands (`state begin-phase`/`planned-phase`/`advance-plan`/`complete-phase`/`milestone-switch`, `phase add`/`add-batch`/`insert`/`remove`/`complete`, `milestone complete`) and deliberately NOT at their early-return error or idempotent-no-op paths, so a refreshed `updated_at` always means something actually moved. Known limits: `phases: []` cannot be told apart from "no ROADMAP" (the 1.0 schema has no diagnostic channel — `planning inspect` is the surface that does), a `Deferred` roadmap phase folds to `pending` (four roadmap statuses, three wire values), and `phases[]` is not milestone-scoped. Contrast with the **Planning Inspect Module**: that is a rich, diagnostic-carrying PULL query a consumer runs; this is a small PUSH artifact a consumer watches. Source of truth: `gsd-core/bin/lib/state-contract.cjs` (generated from `src/state-contract.cts`). Design: `.gsd/phase/feat-3227-state-contract/40-design.md`. Test anchor: `tests/state-contract.test.cjs`.
### Health Diagnostic Types Module
Leaf module owning the `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types shared between the Health Diagnostic Module (the evaluator) and the Health Diagnostic Rule Groups (the eight rule-group files it concatenates). Split out of `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) to break a CJS circular dependency: the evaluator must `require()` every rule-group file to populate `RULES`, and every rule-group file needs these enums/types — if the rule-group files required the evaluator back, the require cycle would resolve `module.exports` before it is assigned. This leaf has no runtime dependency on either side of that cycle. Source of truth: `gsd-core/bin/lib/health-diagnostic-types.cjs` (generated from `src/health-diagnostic-types.cts`).
### Health Diagnostic Module
Module owning the frozen rule-table contract for `validate health`, per ADR-3180 §8.2/§8.3/§8.5 (Phase 11, #3309). Exposes three frozen enums — `SEVERITY` (`error`/`warning`/`info`), `REMEDY_ACTION` (the six real repair actions harvested from `cmdValidateHealth`'s existing `--repair` implementation — `createConfig`, `resetConfig`, `regenerateState`, `addNyquistKey`, `addAiIntegrationPhaseKey`, `backfillMilestones` — plus `advise`, the non-repairable payload every non-actionable finding's fix text becomes), and `REMEDY_RISK` (`none`/`destructive`) — plus the `Diagnostic`/`Remedy`/`Rule` shapes every rule's `check(snapshot: PlanningSnapshot) → Diagnostic[]` signature and every finding's `remedy` conform to. `RULES: Rule[]` is the rule table, fully wired: the static concatenation of the 33 rules exported by the eight Health Diagnostic Rule Groups files below (extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577`; the count is locked by `tests/health-diagnostic.test.cjs`'s frozen RULES assertion and by `scripts/lint-health-diagnostic-rule-table.cjs`, so update all three together). `evaluateRules(snapshot) → Diagnostic[]` runs every rule in `RULES` against one `PlanningSnapshot` and flattens the results, throwing on any two rules sharing a `code` — defense in depth beside the static 1:1 lint guard (§8.2 rule 1, `scripts/lint-health-diagnostic-rule-table.cjs`). `applyRepairs(cwd, diagnostics, repair, backfill) → {applied, refused, details}` is the `--repair`/`--backfill` dispatcher: a `DESTRUCTIVE` remedy (`resetConfig`/`regenerateState` — health.md's own published table: "loses custom settings" / "loses session history") is reported but never executed by `--repair`, a deliberate, disclosed breaking change (§8.3 rule 3) from `cmdValidateHealth`'s current unconditional application; `backfillMilestones` alone among the `NONE`-risk actions is requested by `--backfill` without `--repair`, mirroring `cmdValidateHealth`'s existing gate (`src/verify.cts:2504`). Per-action repair handlers (`runRepairAction`) are REAL, ported behavior-preserving from `verify.cts:2405-2553`'s repair switch — `createConfig`/`resetConfig` (write the default config.json payload), `regenerateState` (backs up and regenerates STATE.md), `addNyquistKey`/`addAiIntegrationPhaseKey` (add a missing `workflow.*` key), `backfillMilestones` (synthesize missing MILESTONES.md entries from archive snapshots) — not stubs. `applied` records only a repair that actually SUCCEEDED (`outcome.success === true`); a thrown or `{success: false}` attempt is recorded in `details` with `success: false` but is never pushed to `applied`. Source of truth: `gsd-core/bin/lib/health-diagnostic.cjs` (generated from `src/health-diagnostic.cts`). Design: `.gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md`.
### Health Diagnostic Rule Groups
Directory `src/health-diagnostic-rules/` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) owning the 33 rules migrated off `cmdValidateHealth` (the count actually summed from each group's exported `RULES` array and locked by `tests/health-diagnostic.test.cjs`'s "RULES" describe block; it was stale at 31 through W028 and is corrected here alongside W029, #3586), split into eight files — one per subject-area group from the design doc's "Rule table organization" table — each exporting a `RULES: Rule[]` conforming to the Health Diagnostic Module's frozen `Rule` shape (worktree-health.cts additionally exports `isActiveWorktreePath(activeCwd, worktreePath, platform?)`, the #3663 win32-only path-casing comparison key helper W027's active-worktree exclusion delegates to — a pure predicate, not a rule). `src/health-diagnostic.cts` concatenates all eight into the single `RULES` table `evaluateRules` runs; no group re-derives its own `Diagnostic`/`Remedy` shapes. Groups: `root-existence.cts` (root `.planning/` + PROJECT.md existence, E002-E004/W001), `state-consistency.cts` (STATE.md vs config/ROADMAP/disk, W002/W011/W021/W026 — W024's state_head freshness check is a disclosed gap, deliberately not migrated), `config-validation.cts` (config.json shape, W003/W004/W022/E005/W008/W012-W016, plus W029 — the tracked-but-gitignored `.planning/` contradiction, #3586), `phase-structure.cts` (phase directory structure, W005/W023/I001/W009), `agent-install.cts` (agent-installation completeness, W010), `roadmap-disk-consistency.cts` (ROADMAP-vs-disk phase matching via the shared `matchPhaseDirs` matcher, W006/W007), `worktree-health.cts` (worktree health, W020/W017/W027), `milestone-archive-hygiene.cts` (milestone archive + root hygiene, W018/W019). Every rule is a behavior-preserving port of one `addIssue` call site in `cmdValidateHealth` (`src/verify.cts`), reading only the parsed `PlanningSnapshot` fields the Planning Snapshot Module already computes — never raw `.planning/` I/O. Source of truth: `gsd-core/bin/lib/health-diagnostic-rules/*.cjs` (generated from `src/health-diagnostic-rules/*.cts`).
### Planning Workspace Module
Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution.
### Workstream Inventory Module
Module owning workstream directory discovery, per-workstream state projection, phase/plan/summary counting, roadmap-declared phase count, active marker projection, and active-workstream collision inputs. Command handlers render list/status/progress outputs from this inventory instead of rescanning `.planning/workstreams/*` directly. Source of truth for the pure projection is `gsd-core/bin/lib/workstream-inventory-builder.cjs` (a Builder Module); the Reader Adapter `gsd-core/bin/lib/workstream-inventory.cjs` collects filesystem inputs and delegates projection to the Builder.
`readStateProjection` (`src/workstream-inventory.cts`) resolves the per-workstream `status`/`current_phase`/`last_activity` fields through the #1760 fallback chain owner (`stateFieldValue`, STATE.md Document Module, #3187): the YAML frontmatter scalar is preferred, falling back to the body field only when the frontmatter side is absent. A frontmatter-only STATE.md — previously projected as `unknown`/`null` for these fields — now reports its real values.
Completion is scoped to the workstream's CURRENT milestone (#2562): `roadmap_phase_count`, `completed_phases` and `progress_percent` describe that one milestone, not the workstream's lifetime, and `status: "milestone complete"` is derived from the current milestone's own close artifacts (an archived `milestones/<version>-ROADMAP.md` snapshot, or `isMilestoneShippedInRoadmap` on its own ROADMAP heading) rather than any project-lifetime shipped marker. That marker is a claim, not a verdict: it is cross-validated against the milestone's own artifacts, and the two signals are checked at different strengths because one check cannot serve both. A live-ROADMAP `heading` marker is refused when the completion ratio is short. An archived `snapshot` is not ratio-gated alone — `milestone complete` moves the milestone's phase directories into `milestones/<version>-phases/` while copying, never truncating, the live ROADMAP, so a CLEAN archive reads 0/N by construction — and is refused on the conjunction of a still-live in-milestone directory (the archive is not clean; a phase was added or reopened after it, reachable because `milestone complete` does not advance `STATE.md`'s `milestone:` field) AND a short completion ratio. The `legacy` project-lifetime fallback is ungated by signal. The cross-check as a whole engages only under milestone scoping, for all three signals: unscoped, the denominator is the whole-roadmap count and membership is everything, so there is no current-milestone artifact set to check against. A refused marker does not fall through to a `STATE.md` field asserting the same thing, and the refusal is reported as `milestone_shipped_unverified`, projected by `workstream list`/`status`/`progress` so it is visible at the CLI — distinct from `status_conflict`, which reports only derived-vs-field disagreement. Membership and the denominator are derived from one canonical key surface — the phase-id owner module's `phaseKeyFromDir`/`phaseKeyFromProse` — with directory membership additionally consulting the Roadmap Parser Module's `getMilestonePhaseFilter` when that filter is genuinely version-scoped (that filter keeps its own internal normaliser, OR'd in, so a divergence can only widen membership); the Builder enforces `completed_phases <= denominator` and throws on violation rather than capping the percentage. A current milestone that is DECLARED but not yet populated is scoped, not unscoped: `versionSectionFound` / `missingExplicitVersion` / a Progress table attributing every row elsewhere each witness that state, and without them scoping switched off and the whole-lifetime fallback reported a milestone with no work done as 100%. Within such a milestone, membership inverts — a directory belongs unless another milestone's row claims it — so a phase scaffolded ahead of the ROADMAP is counted rather than hidden. Scoping is stated by the Reader (`milestoneScoped`) rather than inferred from `currentMilestonePhaseCount > 0`, which cannot represent a scoped, legitimately zero-phase milestone. A ROADMAP that attributes no versions at all matches none of the witnesses, so free-form legacy projects keep their whole-roadmap count. The Reader passes the workstream name to `getMilestonePhaseFilter`/`extractCurrentMilestone` so their `planningDir` resolution targets `.planning/workstreams/<ws>/`, which a loop over workstreams cannot express through `GSD_WORKSTREAM`.
### Project-Root Resolution Module
Module owning project-root resolution from any starting directory. Walks the ancestor chain (bounded by `FIND_PROJECT_ROOT_MAX_DEPTH = 10`) applying five heuristics in order: (0) own `.planning/` guard (#1362), (1) parent `.planning/config.json` `sub_repos` traversal, (2) legacy `multiRepo: true` boolean + ancestor `.git`, (3) `.git` heuristic with parent `.planning/`, (4) nearest-ancestor `.planning/` walk-up (#1414, epic #1411) — a last-resort second walk (same depth bound, stops at `os.homedir()`) that anchors a plain descendant subdirectory of a single-repo project to its nearest ancestor `.planning/` instead of degrading to defaults; ordered after (1)–(3) so `sub_repos`/`multiRepo` resolution always wins (the Resolution Provenance deterministic-anchoring rule). Returns `startDir` when no ancestor qualifies. Sync `node:fs` I/O. Source of truth: `gsd-core/bin/lib/project-root.cjs`; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly.
### Planning Path Projection Module
Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback.
### Reviewer Lane Descriptor Module
Module owning the single declared contract for a **reviewer lane** — one external CLI or model endpoint that `/gsd:review` hands a plan to for independent review (ADR-2782 Phase 1, #2794; subsumes #2690). Before it, a lane was declared across three unrelated surfaces — the roster in `src/review-reviewer-selection.cts`, ~640 lines of hand-authored per-CLI bash in the `invoke_reviewers` step of `gsd-core/workflows/review.md`, and the hardcoded section headings in `write_reviews` — so cross-cutting fixes landed per-leg (#2494 and #2605 were the same empty-output defect filed twice; #2475/#2295/#2272 are the same shape). Interface: `REVIEWER_LANES` (frozen table of the 11 shipped lanes, each declaring `slug`, `flags`, `probe`, `invoke`, `timeoutFloorMs`, `emptyOutput`, `reviewsSection`, `evidenceClass`, `requiresBinaries`, `promptBudgetKey`, `handler`), `PARITY_VIOLATION` (frozen reason enum — adding a reason is three coordinated changes: enum + emitting site + the test locking `Object.keys(...).sort()`), and `checkReviewerLaneParity({descriptor, roster, workflowText}) → {ok, violations[]}`. **The module DECLARES; it does not execute** — `invoke_reviewers` still runs hand-authored legs until Phase 5b (#2799) makes it iterate; what Phase 1 guarantees is that a leg cannot be added, removed, or renamed without the table and the `REVIEWS.md` section moving with it. Field names, **nesting**, and enum members track ADR-2782 D1/D2/D6/D7 so Phase 2 (#2795) harvests the shape into the capability manifest with no translation layer — including `transport` at the **lane level** (a sibling of `probe`/`invoke`, as D1's manifest example places it) rather than nested inside `invoke`, which would read more naturally as a TS discriminated union and is exactly the convenience Phase 2 would have to translate away. **Four** vocabulary widenings were forced by surveying the eleven shipped legs, all additive widenings of closed enums, each forced by a lane that exists today — and **ADR-2782 was amended in the same PR** (its Amendments section, 2026-07-29) rather than left diverging, so Phase 2 implements the validator against the amended vocabulary: `promptChannel: 'none'` (CodeRabbit is fed no prompt — it reviews the working-tree diff), `outputChannel: 'file-arg'` (Codex captures the review through its own `-o/--output-last-message` and discards stdout, #1698), `outputArg` (its companion — knowing the review lands in a file is useless without the argument naming the file), and `flags: string[]` where D1 shows a singular `flag` (Antigravity is selected by both `--antigravity` and `--agy`, which one field cannot express; this also widens D8's uniqueness invariant, enforced here over the flattened flag set). `LANE_SLUG_RE` (`^[a-z0-9][a-z0-9_-]*$`) pins the slug grammar because `LEG_MARKER_RE` can only capture `[a-z0-9_-]`: a slug outside that class is **unmatchable** — its marker can be present and correct and the scan still never sees it — so a violating slug is reported `INVALID_SLUG` rather than reported missing forever. A loud named violation beats a silent miss. The descriptor deliberately does **not** promise uniformity — lane divergence is real and frequently correct (three lanes are HTTP endpoints with no binary; timeout floors genuinely differ; Antigravity needs a three-layer fallback for an upstream stdout bug) — so behavior data cannot express is delegated to a named `handler` (closed first-party enum: `null` | `antigravity` | `openai-compatible`), never to conditionals inside the table. `checkReviewerLaneParity` is the `RULESET.GENERATIVE-FIX` assertion the roster has never had, and it is **bidirectional**: a forward-only check misses the failure it exists to catch (#2718 added a lane leg, #2781 was the drift that followed), so an undeclared leg fails too. Legs are identified by an explicit `<!-- reviewer-lane: <slug> -->` marker rather than inferred from prose shape, because five non-lane bold labels in `invoke_reviewers` share the bold-then-fence shape a heuristic would key on. Section matching is anchored at h2 with an exact ` Review` suffix and no parenthetical, so ADR-1517 reviewer-instance headings (`## OpenCode Review (opencode-deepseek)`) are exempt — ADR-2782 D8: instances are not lanes. Pure and total: no filesystem access, CRLF-insensitive, and **never throws on any input** — every field is validated before use (`MALFORMED_LANE` / `INVALID_SLUG`) rather than trusted, because Phase 2 feeds this same function manifest-derived data from third-party overlays, and a parity gate that crashes on bad input is indistinguishable from one that was never run. Empty input degrades to violations so a read failure is never mistaken for a clean bill of health. Totality, determinism (no leaked regex `lastIndex`), and the invalid-slug contract are `fast-check` property-tested with a pinned seed. Phase 6 (#2800) adds a SECOND, deliberately separate pure gate in the same module — `checkReviewerDocsParity({descriptor, docs}) → {ok, violations, skipped}` with its own frozen `DOCS_PARITY_VIOLATION` enum — answering *what is documented* rather than *what runs*, so a stale doc can never make the runtime checker look red and the seven dependents of `checkReviewerLaneParity` never move. It is the `RULESET.GENERATIVE-FIX` parity assertion this roster had always required and never had (only the Cursor lane ever carried one). Three independent arms: declared flags appear delimited (backticked or bracketed, so a bare flag in a fenced example cannot satisfy the gate) in `docs/COMMANDS.md`, `docs/FEATURES.md` and their four locale mirrors; a `Command:` signature line is held to the full roster and rejects undeclared bracketed lane flags; and the Purpose paragraph beneath it must name every declared `reviewsSection` — the arm that catches the class where a `Command:` line is updated and the line below it is not. Section titles are matched LITERALLY (`llama.cpp` would otherwise let `llamaXcpp` pass). A mirror carrying no `/gsd:review` surface is reported in `skipped`, never failed, so a partial translation is not misread as drift. Its totality is property-tested too, which is how the non-callable-`toString` coercion crash was found before it shipped. Source of truth: `src/review-lane-descriptor.cts`. Test anchors: `tests/review-lane-descriptor.test.cjs`, `tests/reviewer-docs-parity.test.cjs`. See `docs/adr/2782-reviewer-lane-capability-surface.md`.
### Reviewer Lane Invocation Module
Module owning the projection from a **declared reviewer lane** plus resolved configuration to a concrete **invocation plan** — the value a lane is actually run from (ADR-2782 Phase 5b, #2799). Pure: no filesystem, network, subprocess or clock; configuration arrives through a `configGet` seam. Interface: `resolveLanePlan({lane, configGet, runDir, repoRoot, effortArgs}) → {ok, plan} | {ok:false, reason, detail}`, `LANE_UNAVAILABLE` (frozen reason enum — a lane that will not run reports WHY, because the ambiguity between "failed" and "ran cleanly with nothing to report" is the defect class this epic closes), plus `isEmptyReview`, `normalizeHost` and `fileRefPrompt`. TOTAL — a malformed lane yields an unavailable result, never a throw, because third-party overlay manifests reach this seam. `invoke.args` is an **argv template** over a closed four-member placeholder vocabulary (`{{model}}`, `{{effort}}`, `{{output}}`, `{{prompt}}`), not a prefix: the injected pieces do not all go in the same place — `codex` injects the model after its `exec` subcommand and the output file later still, while five lanes end with a bare `-` that must stay last. Each lane's plan was derived FROM its former bash leg, and a golden table asserts all twelve; that table is the strangler-fig substitute for a parallel run. `SpawnPlan` now carries `model` — the configured model **that was actually applied** to the invocation, `null` when a lane declares a `modelConfigKey` but no `modelArg` so the configured value never entered argv (#2295) — and `configString`, the "what counts as unset" normalizer, is now exported and shared with the runner so the plan resolver and the runner's model-recovery arms cannot disagree on the same question.
### Reviewer Lane Runner
Module owning **execution** of an invocation plan (ADR-2782 Phase 5b, #2799): probe, spawn or HTTP call, empty-output policy, and dispatch of the three first-party `handler` modules D6 names (`antigravity`, `openai-compatible`, `opencode`). Replaces ~640 lines of hand-authored per-CLI bash in `invoke_reviewers`. Interface: `runLane`, `probeLane`, `checkEgressHost`, `writeReviewOrStub`, and the handler entry points (`handleOpencodeOutput`, `antigravityArgv`, `antigravityPrompt`, `antigravityWatermark`, `antigravityTranscriptFallback`, `antigravityDiagnostic`, `stampBlindReview`, `runOpenAiCompatible`). Every dependency is injected, so behaviour is testable without a network or a spawn. **Every subprocess call passes `timeout` + `killSignal` + `maxBuffer`** (`DEFECT.UNBOUNDED-SUBPROCESS`): a frozen synchronous spawn cannot be interrupted and hangs a whole CI chunk to its kill with `# fail 0`. Three runtime dependencies disappear here — `jq`, `curl`, and external `timeout`/`gtimeout` — which also closes two platform holes: five lanes were unavailable on stock Windows/Git-Bash for want of `jq`, and the Antigravity lane ran unbounded on stock macOS, which ships neither killer. Owns ADR-2782 D5 rules 2–4: the egress destination is **re-resolved at invocation** and a changed host blocks the lane rather than silently redirecting it; absence of a consent record allows, since first-party lanes are never consent-gated. `antigravityWatermark`'s final read can throw (permissions, mid-write truncation) on a transcript that indisputably exists, which is not the same fact as a genuinely empty or absent one; that case now sets `unreadable: true` on the returned mark rather than folding into `lines: 0`, and `antigravityTranscriptFallback` declines (`''`) for a same-conv-id unreadable mark instead of skipping zero lines and replaying a stale pre-run response (#3118). **The resolved model (#2295)** adds `MODEL_SOURCE`, `UNRESOLVED_MODEL`, `parseModelBanner`, `parseTranscriptModel`, `antigravityModel`, `resolveSpawnModel`, `BANNER_SCAN_LINES` and `MODEL_VALUE_MAX` to the interface: `LaneRunResult` now carries `model: {value, source}`, where `value === null` iff `source === 'unknown'`. The banner arm is gated on `outputTarget.kind === 'file'`, because a stdout lane's own review text would otherwise be scanned as its banner. `antigravityWatermark` now snapshots BOTH transcripts — `lines` for `transcript.jsonl`, `fullLines` for `transcript_full.jsonl` — since they are different files with different counts and one cannot offset the other. The model arm's staleness rule is deliberately looser than the review body's: a matching conv-id means the same `agy` session, whose model is this run's. Every arm is total and degrades to `unknown` — it may never fail a lane (#2295).
### Code Review Depth Module
Module owning the single resolution of a code review's depth tier (#2554), consumed by `gsd-core/workflows/code-review.md`'s `resolve_depth` step. Interface: `resolveCodeReviewDepth({flagDepth, configDepth, overrides, files, repoRoot})`, `REASON` (frozen reason enum — adding a member is three coordinated changes: enum + emitting site + the test locking `Object.keys(...).sort()`), `DEPTH_TIERS` (`quick` < `standard` < `deep`, ordered weakest-first), `LARGE_SCOPE_THRESHOLD`, and the two matching primitives `normalizeRelPath` / `ruleMatchesFile`. Resolution order is `--depth=` flag → strongest matching path rule → `workflow.code_review_depth` → `standard`, and the result reports its own `source` (`flag`/`rule`/`config`/`default`) per `### Resolution Provenance` — a depth with no provenance is what let the operator surface claim a global setting produced a result it did not. `workflow.code_review_depth_overrides` is an ordered array of `{paths, depth}` rules matched against the review's changed-file set; a matching rule REPLACES the global rather than being max'd with it, because folding the global in would make every `quick` and `standard` rule inert whenever the global was stronger — a config that parses and does nothing, which is the silently-discarded failure `### Federated Config` exists to prevent. Escalation is **whole-review, not per-file**: depth is a single scalar handed to `gsd-code-reviewer`, so one matched file raises the tier for the entire review and a sensitive file is never reviewed shallowly. Matching is **segment-aware** path-prefix — a rule naming a directory matches that directory and everything beneath it, and never a sibling whose name merely shares the prefix (a rule for "src/auth" must not match "src/authfoo") — and case-sensitive, following git — the same anchoring rule `### Emitted Artifact Provenance` states for its `sources` prefixes. **Glob metacharacters are a hard configuration error, not sugar for a prefix**: accepting a trailing double-star as a prefix would make a mid-path star look supported while matching nothing, arming a policy the operator believes is live (v1 scope decision on #2554 — no glob engine exists in this tree and none was added; runtime dependencies stay at two). A rule path carrying an interior control character (U+0000-U+001F or U+007F, checked after the glob check so a path that is both a glob and control-bearing still reports the glob reason) is likewise a hard configuration error — an unrejected newline or NUL would otherwise flow through the matched rule into the workflow's provenance string and corrupt the rendered review summary. Malformed rules halt the review rather than degrading to `standard`, since a misconfigured sensitive-path policy reviewing shallowly is the exact hole the module closes. The module is PURE — no fs, no clock, no subprocess, no require — so the workflow reaches it through the same `node -e` idiom it already uses for `src/code-review-flags.cts`; the changed-file list crosses on **stdin**, never argv (`DEFECT.WINDOWS-ARGV-OVERFLOW`). The pre-existing large-scope downgrade (`deep` → `standard` above `LARGE_SCOPE_THRESHOLD` files) moved into this module from the workflow so one seam owns depth end to end; it preserves `matchedRule` through the downgrade so the operator surface can name the rule it overrode. Note the key is registered in the CENTRAL config schema, not as a capability config slice: `### Federated Config`'s `VALID_SLICE_TYPES` admits only `boolean`/`string`/`number`/`enum`, so an array slice is dropped as malformed — the `ship.pr_body_sections` precedent is the one this follows.
### Resolution Provenance
Cross-seam principle (ADR-1411, epic #1411): context resolution — config loading, project-root anchoring, workstream resolution — must report its provenance, not fall open silently to defaults. A resolver anchors deterministically to the project root (one walk-up module, no dependence on an arbitrary descendant cwd), returns *what* it resolved **and** *where it came from* (`source`/`degraded`), and surfaces a diagnostic when a *configured* input resolves empty (`not configured` and `configured-but-empty` are distinguishable). The resolution-side analog of ADR-227 (input-validation shape). Target seams: Config Loader Module (`loadConfig` → `ConfigResolution { config, source, degraded }`), Project-Root Resolution Module (single nearest-`.planning/` walk-up, retiring ad-hoc resolvers like `resolvePlanningCwd`), I/O Module (`Resolution<T> { value, configured, reason, warnings }` output envelope). A configured input resolving empty without a reason is a CI-guarded regression. **P1 (nearest-.planning/ heuristic) shipped in #1413; P2 (loadConfigResolved + agent-skills diagnostic) shipped in #1415 / closes #1366**: `loadConfigResolved` now implements the Config Loader seam target; `cmdAgentSkills` uses `findProjectRoot` + `loadConfigResolved` and emits `configured`/`reason`/`source`/`degraded` in its `--json` IR. **Corrupt is not absent (ADR-1411 amendment 2026-07-26, epic #1879 Phase 0 / #2674):** the principle above governs a resolution *miss*; input that is present but *not usable* (a `SyntaxError`, an errno such as `EACCES`/`EIO`, or a malformed structure with no exception at all) is a distinct class that must stay distinguishable from genuine absence. The defect in that class is not the fallback — ADR-227 requires malformed input to be coerced rather than propagated, and this ADR already permits a fallback — it is that the fallback is **invisible**. So every current return value is preserved and the cause is made visible by one of two mechanisms: **in-band**, where the result already carries a provenance envelope, name the cause in it (`ConfigResolution` gains a `reason`; `Resolution<T>`'s four documented values all describe a miss, so new unusable-input values are introduced with the first adopter) and also expose it on the surface callers actually use, since a `reason` no caller reads is an unreachable field; **out-of-band**, where the read returns a bare sentinel or a plausible default it cannot extend, keep that value and emit a deduplicated `stderr` diagnostic keyed on resolved-path + errno, reusing the `_warnedUnknownConfigKeys` guard pattern. The diagnostic is unconditional — a deliberate divergence from ADR-227's never-implemented `GSD_DEBUG` opt-in, since an opt-in nobody sets is the same silence. Throwing is **not** the cluster's answer — it stays confined to ADR-227's genuinely-fatal carve-out, decided per call, never inferred from the return shape.
### Resolution Convention
Diagnostic-output convention for the Resolution Provenance principle (ADR-1411 P3, #1416). Config-interpreting read verbs expose `Resolution<T> { value, configured, reason, warnings }` (`src/resolution.cts`); agent-skills is the first adopter, where `value = { block, skills_count }` and `source`/`degraded` remain config-provenance extras outside the envelope. Other read verbs expose at least `warnings[]` (e.g. capability-state `{ runtimeConfigDir, capabilities, warnings? }`) without `configured`/`reason`, which are meaningful only for config-interpreting verbs. Mutation verbs expose `warnings[]` (advisory) PLUS `errors[]` (operation-not-applied), e.g. capability-writer `{ capabilities, warnings, errors }`. The shared seam across all shapes is `warnings: string[]`; a single generic `Resolution<T>` across read+write verbs was rejected by the deletion test (`configured`/`reason` are meaningless for capability verbs; `errors[]` cannot fold into `warnings[]`) — ADR-1411 P3 amendment. Recurrence prevention is delivered by P4's CI guard (a configured input resolving empty must carry a `reason`), not by a shared envelope. A CI guard (`scripts/lint-resolution-provenance.cjs`, wired into `lint:ci`) enforces that every registered config-interpreting read verb keeps a `configured_empty`/`not_configured` contract test; the registry in that script is the registration point for future verbs (ADR-1411 P4 / #1417).
### Unusable Input Diagnostic Module
Leaf module owning the **out-of-band** half of ADR-1411's "corrupt is not absent" amendment (epic #1879). Where a read already returns a provenance envelope the cause is named in-band (`ConfigResolution.reason`, #1880); where a read returns a bare sentinel or a plausible default it cannot extend, the return value is preserved exactly and the cause is surfaced here instead. Interface: `UNUSABLE_REASON` (frozen reason enum — one entry per condition that has an emitting call site; adding a reason is three coordinated changes: enum + call site + the test locking `Object.keys(...).sort()`), `warnUnusableInput({reason, source?, content?}) → boolean` (returns whether this call actually wrote, so tests assert emission *counts* on a typed surface rather than scraping stderr), plus the `_resetUnusableInputWarningsForTests` / `_unusableInputWarningCountForTests` seams. Dedup key is `<normalized source>\0<reason>` — **both halves are load-bearing**: keying on the path alone would let a second, different fault on the same file go unreported, and keying on message prose would couple the guard to wording (ADR-1411 dedup clause). Path separators are deliberately **not** normalized: an earlier revision folded backslashes to `/` so two spellings of one Windows path would not double-report, but a backslash is a legal filename character on Linux and macOS, so that folding collapsed two genuinely distinct POSIX files onto one key and swallowed the second file's diagnostic. The trade is now one-directional — two spellings of one Windows path may report twice (noise), but two distinct files can never silence each other (lost signal), and ADR-1411 ranks the swallow the worse failure; ASCII control characters are stripped from the source before it is keyed or written, because the key separator is NUL (a crafted path could otherwise forge a collision) and because a path carrying ANSI escapes would replay into the operator's terminal. Callers with no path (in-memory content) fall back to a short content digest so *different* bad inputs still key differently. The diagnostic is **unconditional** — a deliberate divergence from ADR-227's never-implemented `GSD_DEBUG` opt-in, since "an opt-in nobody sets is indistinguishable from the silence #1879 is about" — and **never throws**: a failed stderr write is swallowed so a degraded read is never escalated into a crash. Adopted by `extractFrontmatter` (#1882, `frontmatter_unterminated`) and by `getRoadmapPhaseInternal`/`getMilestoneInfo` (#1881, `roadmap_unreadable`); `planning-workspace`/`verify` (#1883) follow. Tenth site: `cmdMigrateConfig` (Config CRUD Module) and `loadConfigResolved` (Config Loader Module) emit `config_section_not_object` when a legacy-key migration's destination section holds a non-object (#3760). The detecting module, `configuration.cjs`, deliberately does NOT emit: it reports in-band as `skipped[]` and its callers — which hold both the resolved path and an unconstrained dependency budget — do the emitting, because `configuration.cjs` must stay loadable with no sibling requires under the #3571 install-layout contract. #1881 detects on the errno alone: `platformReadSync` returns `null` for ENOENT and its callers convert that to an errno-less Error, so reporting unconditionally in those catches would flag every project without a ROADMAP.md as corrupt. Exists as a shared seam rather than a per-site copy because four sites need identical behavior and four hand-rolled copies is `RULESET.GENERATIVE-FIX` by construction. Source of truth: `gsd-core/bin/lib/unusable-input.cjs` (generated from `src/unusable-input.cts`). Test anchor: `tests/unusable-input.test.cjs`. See Resolution Provenance, Config Loader Module.
### Worktree Safety Policy Module
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult` (per-entry gauntlet: branch → base → deletions → **advisory scope conformance (#2596)** → SUMMARY-rescue → clean-worktree → merge → remove; the scope check compares the branch's committed diff against the entry's declared `files_modified` and appends `WAVE_CLEANUP_WARNING`-coded entries to a `warnings` channel WITHOUT touching `ok` — an advisory, not a gate, and skipped entirely with no git call when no scope was declared), `planWaveScopeConformance(changedPaths, declaredFiles, branch) → WaveCleanupWarning[]` (pure; literal-prefix path coverage deliberately mirroring the submodule-intersection gate's glob-prefix rule rather than introducing a second matcher; over-accepts by design because a false alarm costs an advisory more than a miss), `isSummaryArtifactRelPath(relPath) → boolean` (the single definition of "executor-written SUMMARY artifact", shared with `defaultFindSummaryFiles` so the rescue walker and the scope exemption cannot drift), `WAVE_CLEANUP_WARNING` (frozen advisory-code enum: `scope_out_of_declared`, `scope_check_unavailable`), `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb), `planWorktreeCreate(fields) → WorktreeCreatePlan` (write-strict `worktree create` planner — same missing-field-hint and `normalizeCleanupManifestEntry` validation as `planWorktreeRecordAgent`, pure/no-git), `executeWorktreeCreatePlan(plan, repoRoot, deps) → WorktreeCreateResult` (bounded `git rev-parse --verify` base check THEN `git worktree add -b <branch> <path> <base>`; fail-closed `base_unresolved`/`git_timeout`/`worktree_add_failed`; returns `cwd` — the working directory an executor spawn would use), `cmdWorktreeCreate(cwd, args, deps) → WorktreeCreateCmdResult` (CLI verb: requires `--root` — confinement is mandatory, not opt-in; omitting it fails closed with `reason:'root_required'` before any git side effect, rather than silently creating an unconfined worktree (#3050); plans, creates the worktree, then appends the manifest entry so it is immediately manageable by cleanup-wave/reap-orphans; dedupes by `(worktree_path, branch)`). #2584 ADR-1239 Codex-binding amendment, Phase 2: `worktree create` is the git-worktree-creation primitive for `dispatch.isolation: orchestrator-worktree` hosts — consumed since #2584 Phase 3 — `executor-isolation-dispatch.md` calls it to create the worktree an `orchestrator-worktree` host is then process-spawned into. `worktree record-agent` / `worktree create` accept an optional `--files` recording the plan's declared scope, consumed by the advisory scope-conformance check above; a blank or omitted value leaves the 4-field on-disk entry shape unchanged. Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps) → {root, reason}` (a projection over `resolveWorktreeContext`; returns the `reason` alongside `root` — a `git_timed_out` reason means `root` is a best-effort cwd fallback, not a confirmed resolution, and callers must surface that risk rather than trust it silently, #3050) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
### Worktree Lifecycle Module
Workflow contract seam covering agent worktree lifecycle orchestration rules. The `worktree_branch_check` block lives in one canonical fragment (`gsd-core/references/worktree-branch-check.md`) that `execute-phase.md`, `quick.md`, `diagnose-issues.md`, and `execute-plan.md` embed at dispatch. Key invariants: `worktree_branch_check` is **verify-only and fail-closed** — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via `git symbolic-ref`; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; on base mismatch the sub-agent halts with `exit 42` and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at `execute_waves` entry that resolves the worktree root and refuses drift into an agent worktree (#48); #1856: that refusal now also reports what the agent worktree holds — commits ahead of the resolved base and uncommitted files, both with true counts plus a truncation notice — and the commit/switch/merge-or-cherry-pick sequence to integrate them, because `re-run from the orchestrator worktree` alone silently meant abandoning work that lives only on the agent branch. The refusal condition and exit code are unchanged, and every added command is diagnostic and `|| true`-guarded so a failure degrades to the plain refusal; cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`.
### Worktree Root Resolution Adapter Module
Adapter Module owning linked-worktree root mapping and metadata-prune policy (`git worktree prune` non-destructive default) for planning/workstream callers.
### Git Query Module
Module owning bounded, never-throw git repository introspection — the single seam for read-only git queries that degrade gracefully rather than throwing. **Adapter 1 — base-branch detection** (`gsd_run query git.base-branch`): Implements a full precedence ladder: (1) `git.base_branch` from the EFFECTIVE configuration — since #3648 this tier is resolved by the Config Loader Module's `loadConfig`, not by a direct `.planning/config.json` read, so it carries the root+workstream deep merge, flat-then-nested key lookup, and builtin/federated defaults that authority applies; (2) `git symbolic-ref --short refs/remotes/origin/HEAD`; (3) `git remote show origin` HEAD branch (authoritative when origin/HEAD is unset — the common case for `git init + remote add + fetch` without `set-head`); (4) local branch existence (`master` present and `main` absent → `master`; `main` present → `main`); (5) `"main"` last-resort default. All git subprocesses are bounded with timeouts (5–15 s) and degrade gracefully to the next tier; the function never throws. The plain base-branch adapter also resolves through the Config Loader with `persist: false`: it retains loader stderr diagnostics while suppressing legacy-key migration; its stdout remains the resolved branch string. Replaces duplicated per-workflow bash detection that silently fell through to `:-main` on master repos (#1146). **Adapter 1a — protected-branch predicate** (`gsd_run query git.base-branch --is-protected <branch>`, #3552): `resolveProtectedBranchStatus` answers whether a branch is shared-and-protected, resolving the base branch through the same ladder and extending it with the optional `git.protected_branches` list, so a git-flow project whose GitHub default (`main`) differs from its integration branch (`develop`) can protect both. Matching is by exact name — no globs. Two invariants distinguish this adapter from the plain query and must hold together: it **fails CLOSED**, rendering `true` with a stderr diagnostic when a tier-2/3/4 git query **timed out or could not be spawned** (`verified:false`, #3057 B4) — note the narrowness, since a git command that RUNS and exits non-zero is `verified:true` by that same contract, so a cwd that is not a repository at all answers `false`, not `true` — and it **must not write**, so its config read passes `persist: false` and the Config Loader's normalize-then-write-back is suppressed — a predicate invoked on every `execute-phase` and `ship` run cannot be allowed to rewrite the config it is asking about (#3648). Per-entry validation drops only unusable `git.protected_branches` names and reports each on stderr, rather than discarding the whole list, so a hand-edited config cannot fail the guard open. Consumed by `execute-phase.md` (`handle_branching`, "none" strategy) and `ship.md` (preflight); both emit an advisory warning and continue. **Adapter 2 — worktree-info detection**: `gitWorktreeInfoInternal` (`git rev-parse --is-inside-work-tree` + `--show-toplevel`), absorbed from the Core module when the `core.cjs` re-export spine was retired and aligned to this module's bounded-timeout / degrade-don't-throw convention (worktree-info detection is a query concern, distinct from the Worktree Safety Policy Module's lifecycle policy). **Adapter 3 — phase change-set detection** (#1953): `phaseStartCommit` resolves the commit that ADDED a phase's `PLAN.md` (`git log --diff-filter=A -1`) — the anchor for "what did this phase touch", since STATE.md records no phase-start sha — and `changedFilesSince` returns the changed paths from that anchor to HEAD. `changedFilesSince` uses `-z` with `core.quotepath=false` and splits on NUL, because git otherwise quotes and escapes non-ASCII paths (lossy round-trip) and a `\n` split corrupts a filename containing a newline; the ref precedes a literal `--` so a dash-leading path cannot be read as an option. Both bounded at 15 s and both degrade to `null`. Consumed by the Complexity Trigger Module. Source: `src/git-base-branch.cts` → `gsd-core/bin/lib/git-base-branch.cjs`. Wired into `execute-phase.md`, `quick.md`, `ship.md`, `complete-milestone.md`, and `pr-branch.md`.
`SEAM.git-query-readonly-seam.owns=bounded, never-throw git repository introspection — base-branch detection, worktree-info detection, phase change-set detection`
`SEAM.git-query-readonly-seam.enforced-by=test:tests/git-base-branch.test.cjs`
### Complexity Trigger Module
Leaf module (imports only `node:fs`/`node:path`) owning per-function complexity measurement and the refactor-proposal decision, behind the opt-in `refactor-trigger` capability (#1953, ADR-1953). `analyzeSource` scores each function by **decision-point counting** over comment- and literal-stripped source — base 1 plus one per `if`/`else if`/`for`/`while`/`do`/`case`/`catch`/`&&`/`||`/`?:`, explicitly NOT `?.`, `??`, bare `else`, or `default:`. The stripper preserves length and newlines so line numbers survive, keeps `${…}` interpolations as code, and disambiguates a regex literal from division by the preceding significant token; an unterminated literal returns `REFACTOR_ANALYZER_UNPARSEABLE` rather than an approximate number, because a silently-wrong score is worse than no score. `evaluateCandidates` flags a function when its score exceeds `refactor.complexity_threshold` **or** its growth over its anchor exceeds `refactor.complexity_jump_delta` — both strictly greater, matching ESLint's `complexity: {max: N}`. The baseline is an **anchor**, not a rolling value: set on first observation, never advanced by a plain evaluate, moved only by `reanchorBaseline` on disposition — so the delta accumulates since the last conscious decision and slow creep is caught a phase before the absolute threshold reaches it. Stored at `.planning/complexity-baseline.json`; proposals at `${PHASE_DIR}/${NN}-REFACTOR.md`. Frozen `REASON`/`VERDICT` enums are the typed surface tests assert against. _Avoid_: "the complexity gate" — this capability declares no gate; strict mode records a `deviation` window and the Broken-Windows Ledger's ship gate does the blocking. Sources: `src/complexity-trigger.cts`, `src/refactor-trigger-command-router.cts` (CLI family `gsd-tools refactor`).
### Runtime Name Policy Module
Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`.
### Host Runtime Detection Module
Pure, no-write Module owning the **detection rung** of runtime identity — ADR-2313 Phase 5 (#3245, folded from #2320). The Runtime Name Policy Module normalizes the two *explicit* signals (`GSD_RUNTIME`, `.planning/config.json:runtime`); this module answers the different question those two cannot: *which host is this process actually running inside* when neither is set. Before it, `init` reported `agent_runtime: claude` inside a Codex session, because the ladder ended at a hardcoded default. `detectHostRuntime(deps?) → {runtime, source, signal}` is the typed surface tests assert against (`source` ∈ `session-env|config-home|none`); it probes, in order, the frozen `CODEX_SESSION_ENV_SIGNALS` table (`CODEX_SANDBOX`, `CODEX_SANDBOX_NETWORK_DISABLED` — injected by Codex into shell-tool children per openai/codex `AGENTS.md`; absent under `sandbox_mode = "danger-full-access"`, so best-effort), then an explicitly-exported `CODEX_HOME` whose `config.toml` exists — the marker FILENAME is single-sourced from the Update-Context Module's `inferPreferredRuntime` (`CODEX_CONFIG_MARKER`, re-exported here), while the TRUTHINESS RULE deliberately differs: `inferPreferredRuntime` accepts a bare, unchecked `CODEX_HOME` as sufficient to resolve an update context, whereas this module additionally requires the marker file to exist, because it asserts session identity rather than resolving an update context and needs the stronger signal — a difference pinned by a test in `tests/host-runtime-detection.test.cjs` rather than left implicit. `resolveReportedRuntime(projectDir, deps?)` composes the whole ladder: `GSD_RUNTIME` > config `runtime` > detection > `'claude'`. Three invariants are load-bearing and each has a test: it **never writes** (#2297 shared-defaults poisoning — no `~/.gsd/defaults.json`, no config mutation); it **never shells out**, so there is no subprocess to time-bound and no degraded-on-timeout path to design; and the **default `~/.codex/config.toml` is never probed**, because every machine that has run Codex carries that file and probing it would misreport Claude Code sessions as codex. _Avoid_: calling this "runtime resolution" — `resolveRuntime` (Runtime Slash Module) keeps its own frozen `GSD_RUNTIME > config > 'claude'` contract and its 71 dependents, including `formatGsdSlash`'s command-style decision, are deliberately untouched; only `withProjectRoot`'s reported `agent_runtime` consumes the detection rung. Sources: `src/host-runtime-detection.cts` → `gsd-core/bin/lib/host-runtime-detection.cjs`; the `resolveExplicitRuntime` seam it composes lives in `src/runtime-slash.cts`. See ADR-2313.
### Host-Integration Interface
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; consumed by the phase scheduler since #2584 Phase 3 and, since #2652, by every single-agent dispatch site — `quick.md`, `diagnose-issues.md`, `execute-plan.md` — which resolve it through the canonical `gsd-core/references/dispatch-isolation-gate.md` rather than branching on a runtime id; and, since #2486, by the runtime-neutral diagnostics — `/gsd:settings` gates its Worktrees recommendation and `/gsd:health` raises W025 from this axis, read through the sentinel-free `inspect-dispatch-isolation` verb). `resolveOrchestratorExec(orchestratorExec, cwd) → { ok:true, command, args, cwd } | { ok:false, reason }` (#2584 Phase 2, pure, no I/O — resolves the `runtime.orchestratorExec` descriptor field, a sibling of `runtime.hostBehaviors` in `capability.json` carrying `{command, args?, cwdFlag?}`, into the concrete argv/cwd a process-spawn primitive would use for a `dispatch.isolation: orchestrator-worktree` host; appends `[cwdFlag, cwd]` to `args` when `cwdFlag` is a non-empty string, e.g. codex `exec --cd <cwd>`, opencode `run --dir <cwd>`, kimi `--work-dir <cwd>`; when `cwdFlag` is `null`/absent — kimi-code's process-cwd case — no flag is appended and `cwd` alone is returned for the caller to bind via the subprocess's own working-directory option; fail-closed `missing_command`/`invalid_cwd`/`invalid_args`/`invalid_cwd_flag`; CONSUMED since #2584 Phase 3 — `routeDispatchIsolation` resolves it into the `exec` field of `gsd_run query dispatch-isolation --json`, and `gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md` process-spawns that `command`/`args`/`cwd`; #2652 adds a second consumer, `_negotiatedDispatchIsolation`, which probes it at install time against a placeholder target to decide whether an `orchestrator-worktree` declaration actually resolves). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016.
### Statusline
Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). `readGsdState()` is workstream-aware (#2850): when the walk-up finds no flat `.planning/STATE.md` but lands on a `.planning/workstreams/` directory, it resolves the active workstream via `resolveActiveWorkstream` (`active-workstream-store.cts`), called with an empty args array — only its env>store precedence applies for this caller, since the CLI leg is inert without argv — and `planningPaths`/`listAvailableWorkstreams` (`planning-workspace.cts`) for path/mode resolution, the same seams every other workstream-aware command uses, and reads that workstream's `STATE.md` instead. The store tier is `peekActiveWorkstream`, a read-only sibling of `getActiveWorkstream` that never deletes a stale/invalid pointer file — a renderer invoked on every prompt must never mutate persistent state as a side effect of drawing a screen (`getActiveWorkstream`'s self-heal is correct for a command, not a render). When workstream mode is detected but nothing resolves, it returns a `{noActiveWorkstream:true}` sentinel that `formatGsdState`/`formatGsdStateCompact` render as `"no active workstream"` — observable, never silent emptiness. Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens`, `statusline.state_format`, `statusline.show_git` and `statusline.show_state_freshness`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. Config resolution for every `statusline.*` key is centralized in `resolveStatuslineOptions(cfg)` — the two entry points (`runStatusline()`, the stdin path, and `renderStatusline(data)`, the test-facing renderer) previously duplicated it byte-for-byte, and a single resolver is what keeps a newly-added key from reaching only one of them (#2734). The opt-in **STATE.md freshness marker** (`statusline.show_state_freshness`, #2734) renders `state ~N commits back` in both renderers when STATE.md's `state_head` stamp is at least `STATE_HEAD_ADVISORY_COMMITS` (20) commits behind HEAD — the same threshold `validate.health`'s W024 uses, not `> 0`, because `commit_docs: true` advances HEAD by one on every STATE sync and a `> 0` threshold would alarm permanently on a fresh project. It follows the git segment's impure-reader → pure-IR → pure-formatter shape (`readStateHeadCommits` → `deriveStateFreshness` → `formatStateFreshness`), spends exactly one bounded `git rev-list --left-right --count` per render (ancestry and distance in one spawn) and none when disabled, and mirrors `src/state.cts`'s hash fence and `projectOwnsItsRepo`/`sub_repos` degradation guards hook-side rather than requiring `state.cjs` on the per-render path; `tests/gsd-statusline.test.cjs` binds the two copies by **behavioral** differential parity against `readStateHeadFreshness`, not a source comparison. Every degradation yields the tri-state *unknown* (marker absent), never a "fresh" claim the project cannot substantiate. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope.
### Install Engine Module
Module owning the layout-driven runtime-artifact install pipeline — `installRuntimeArtifacts`, `uninstallRuntimeArtifacts`, `installOpencodeFamilySkills`, and their cluster helpers (`_copyStaged`, `_snapshotDir`/`_restoreDir`, legacy-migration + GSD-entry pruning, user-artifact preserve/restore). Extracted from the 12k-line `bin/install.js` (ADR-1239 Phase B, #1679) so adapters import the engine instead of reaching into the installer. Commit-attribution resolution stays in `bin/install.js` and is injected via a `resolveAttribution` parameter (the engine takes no config I/O). Source: `src/install-engine.cts` -> `gsd-core/bin/lib/install-engine.cjs`.
### CommonJS Marker Module
Module owning the `{"type":"commonjs"}` module-type marker GSD writes beside its own staged `.js` files (#2544) — `markerPathFor`, `classifyMarker`, `ensureCommonJsMarker`, `removeCommonJsMarker`, and the `COMMONJS_MARKER` / `COMMONJS_MARKER_CONTENT` constants. Exists so two rules are enforced in exactly one place: **write only where GSD owns the contents** (`hooks/`, and the `nativePlugin.dir` for runtimes declaring one — never the shared runtime config root, which on OpenCode and Kilo is documented, user-writable territory for local-plugin npm dependencies), and **never overwrite a file GSD did not write**. Ownership is decided by exact content match, the same predicate the uninstall path always used; `classifyMarker` is the shared seam behind both the write and the remove path so install and uninstall cannot drift apart again. Fails **closed** throughout: `lstat` (not `existsSync`) so a dangling symlink is never classified `absent`; anything that is not a regular file is `foreign`; a present-but-unreadable file is `foreign`, never downgraded to the permissive answer; the write uses `flag:'wx'` so anything appearing between classify and write yields `preserved-foreign` rather than a follow-or-overwrite. `ensureCommonJsMarker` **never throws** — an unwritable directory returns `failed` and both call sites warn and continue, matching the best-effort posture of every other marker interaction. The stale pre-#2544 config-root marker is retired by `src/installer-migrations/007-retire-config-root-commonjs-marker.cts` (and, for kimi's out-of-configDir root, by `bin/install.js` directly). Source: `src/commonjs-marker.cts` -> `gsd-core/bin/lib/commonjs-marker.cjs`. Test anchor: `tests/commonjs-marker.test.cjs`.
### Installer Migration Authoring Guard Module
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
### Installer Module
Primary installer for all runtimes. Single production file: `bin/install.js` (hand-authored JS — it is NOT generated from `src/*.cts`; ADR-1508 keeps it hand-authored deliberately, and no `npm run build` step emits it). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (18 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, hermes, kimi, kimi-code, kilo, opencode, pi, qwen, trae, windsurf, zcode). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`) to a Claude Code settings object and filters out the retired legacy forms (`GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS` #2278; `GSD_CLAUDE_LEGACY_DENY_PERMISSIONS` #4221 — the `Read(.env*)` deny rules are retired in favor of the managed `gsd-secret-read-guard.js` hook, and an emptied `deny` array is deleted); called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Five runtimes with non-recursive skill loaders (cline, qwen, hermes, augment, trae) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). claude (reverted from nested per #924 — the Skill tool errors on unrouted names) and antigravity (one-level scan, but concrete skills must be top-level discoverable) plus the remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
### I/O Module
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). **Degraded result vs fault (ADR-2980, #2980):** the two emitters are a deliberate two-channel failure contract, not a drift. A **fault** is `error(message, reason)` — stderr, exit **1**, structured `{ok:false,reason,message}` envelope under `--json-errors`. A **degraded result** is `output({ error: … })` — stdout, exit **0**, `--json-errors` does not apply — and means the command ran to completion and is reporting a condition (absent artifact, and in practice also missing-argument and unusable-input cases) through its result; a caller detects it by inspecting the payload, never by exit code. Ratified across **60 sites in 9 modules** (`state` 25, `verify` 8, `workstream` 7, `frontmatter` 6, `commands` 5, `template` 3, `gsd2-import` 2, `phase` 2, `roadmap` 2) because normalizing them to exit 1 is a Hyrum's Law break over a CRITICAL radius (`get_impact(cmdStateSnapshot)`; `output` has 170 direct callers). #2966/#2980 record "42 sites" — that counts only literals whose FIRST key is `error` (the `output\(\{\s*error:` regex); 18 more put another key first (`{found:false, error}`) and are identical in contract, so 60 is the population and 42 is a subset. New code prefers the fault path or a named-field result (`{updated:false, reason}`), not a 61st site. Known cost carried by the decision: the exit code does not distinguish absent from unusable, which is ADR-1411's "corrupt is not absent" open edge. Docs: `docs/json-errors.md` → "Degraded results vs faults". Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
### Markdown Sectionizer
Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `stripInlineCode(content) → string` (per-line CommonMark inline-code-span stripper — removes `` `code` `` spans while leaving fenced blocks to `stripFencedCode`; #2365); `scanInlineCodeSpans(content) → InlineCodeSpan[]` (locates every inline code span as `{ start, end, content }`, offsets into the full string and spans never crossing a `\n`; callers that need the span CONTENT (e.g. api-coverage's package-name evidence, #2365) use this, callers that just want spans gone use `stripInlineCode`); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `<tagName>…</tagName>` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern); `withSection(content, target, edit) → string` (resolve the section whose heading matches `target` — exact heading text or a `HeadingToken` predicate — and run `edit(body)` against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or `edit` returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).
### Markdown Table Model
Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-table.cjs`; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: `parseMarkdownTable(sectionText) → Result<MarkdownTable>` (parses the first GFM pipe table found; typed `{ok:false,reason}` parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); `MarkdownTable` (`{columns: string[], rows: Record<string,string>[]}`, rows addressed by column name, not position); `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — re-exported from the Write-Set Module, the ADR-2143 §5 single source of truth for this shape, so existing importers of `Result` from `markdown-table.cjs` are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`; the two never mix); `TABLE_SCHEMAS` (`Record<string, CanonicalTableVariant[]>` — the canonical column-header variants for every GFM table GSD parses or generates: `RoadmapProgress` flat/milestone-grouped, `RequirementsTraceability`, `QuickTasks` no-status/with-status, `Security` trust-boundaries/threat-register/accepted-risks/audit-trail); `matchTableSchema(columns) → {id,label}\|null` (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (`tests/markdown-table.test.cjs`) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` is the first consumer: it locates the Progress section via the Markdown Sectionizer's `collectSection` and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed `Status` was always the 3rd cell, which broke for the 5-column milestone-grouped `Milestone` variant).
### Write-Set Module
Shared fail-loud `Result<T>` and per-surface write-set contracts (`gsd-core/bin/lib/write-set.cjs`; ADR-2143 §5/§6, epic #2143). Pure, Node built-ins only, no I/O. Exports: `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — ADR-2143 §5 fail-loud parse shape, never a bare `null` a caller can mistake for "empty but fine"; the single source of truth `markdown-table.cjs` re-exports so its existing importers are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`); `WriteOutcome` (`{surface: string, applied: boolean}` — one surface's outcome within a multi-surface write); `WriteSet` (`WriteOutcome[]`); `writeSetComplete(ws) → boolean` (true only when the set is non-empty AND every surface applied — ADR-2143 §6's "no OR-into-one-flag" rule: a command that mutates more than one surface must not collapse independent surface outcomes into a single boolean, the anti-pattern that let a checkbox-only partial write (#2140) report full success). `milestone.cts`'s `requirements mark-complete` handler is the first consumer: it reports a `write_set` (`checkbox`/`traceability` surfaces) and `write_set_complete` alongside its existing `updated`/`marked_complete`/`already_complete`/`not_found`/`table_unmatched` fields, which remain computed exactly as before — the write-set is additive, structured ADR-2143 documentation of the same per-surface facts #2140's tactical fix already exposed via `table_unmatched`.
### Roadmap Parser Module
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`, `isMilestoneShippedInRoadmap`, `withPhaseSection`). Milestone shipped/active heading classification is owned here (#2562): `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark THIS milestone shipped" from heading and `<summary>` lines only — never a bullet that merely names the version — with the version token boundary-matched so `v2.0` does not match inside `v2.0.1`. `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional trailing workstream name so their `planningDir` resolution targets `.planning/workstreams/<ws>/`; omitted, it resolves exactly as before (including the `GSD_WORKSTREAM` fallback). `getMilestonePhaseFilter` exposes `versionScoped`, true only when the returned phase set really is one milestone's — consumers must not read `phaseCount` as a current-milestone denominator otherwise — and `versionSectionFound`, true whenever the requested version's section was located at all. The two differ precisely for a located-but-EMPTY section: it falls through to the zero-count pass-all degrade, which resets `versionScoped` to false, leaving `versionSectionFound` the only surviving evidence that the milestone exists rather than being absent. `missingExplicitVersion` covers the complementary shape (versioned roadmap, no section for this version). `withPhaseSection(content, phaseId, edit)` resolves a phase's `### Phase N` detail-section heading via the #2121 phase-id source (`phaseMarkdownRegexSource`) and delegates to the markdown-sectionizer seam's `withSection`, so a per-phase ROADMAP edit is bounded to that phase's own section (ADR-2143 §4). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`, `markdown-sectionizer`, and — since #1881 — `unusable-input` for the out-of-band diagnostic) — no `loadConfig`, no other core dependency. An unreadable ROADMAP.md is reported rather than collapsed into the same sentinel as a genuinely absent one; absence itself stays silent, and neither lookup gains a throw (the #2245 audit records that `src/state.cts` removed its defensive try/catch on the strength of `getMilestoneInfo` never throwing). Milestone WINDOWING — which headings bound a milestone — is owned here as of #3184 (epic #3180 Phase 2, ADR-3180 Decision 1): `computeMilestoneSectionEnd` (the section-end walk, formerly duplicated as two distinct nested `computeSectionEnd` functions plus an inline third copy in `getMilestonePhaseFilter`'s `versionOverride` branch), `locateMilestoneHeadings` (heading location, version token boundary-matched with `\b`, **NOT** the stricter `(?![\w.-])`: `v2.0` therefore DOES match inside `v2.0.1`, and a milestone STATE of `v8.0` legitimately selects a live `## v8.0-B …` over a closed `v8.0-A` sibling — deliberate, load-bearing #730 behavior that ADR-3180 Amendment 2 tried to tighten and then reverted; the earlier text here described that reverted alternative as if it had shipped, corrected by #3216), `listMilestoneHeadings` (#3216 — the version-AGNOSTIC enumeration of every milestone heading in document order, sharing ONE grammar source with `locateMilestoneHeadings` so the two cannot drift; `locateMilestoneHeadings` is now a version-filtered view over it rather than a second expression of the pattern). Milestone IDENTITY — which milestone is current and what it is CALLED — is owned by `getMilestoneInfo`, which since #3216 binds to that same locator instead of its own heading regexes and returns a `ScopedResult<MilestoneInfo | null>`: a name retains parentheses and drops a trailing `✅`/`📋`/`🚧` marker, a heading remainder with no letter or digit anywhere is heading structure rather than a curated name and is refused as `name: null`, so a name-then-version H1 (`# Roadmap: Project — Name (v1.13)` — the shape a first-ever ROADMAP.md drifts into) reports the rule-6 TRUNCATED identity instead of the literal `)` left after the version token (#4134), a `### Phase N …` heading is never the milestone heading (#3197), and an identity that cannot be determined returns a non-`COMPLETE` scope rather than the former `{version:'v1.0', name:'milestone'}` default, which was output-identical to a successful read of a genuine v1.0 project. `buildStateFrontmatter` and `archivePhaseDirectories` branch on that scope, so a fabricated identity is never persisted to `STATE.md` nor used as a `milestones/<version>-phases/` path component, `sliceMilestoneWindow` (the one composition of locate → prefer-non-closed → section-end, so a consumer cannot re-assemble its own window from the primitives), and `isMilestoneBoundedInRoadmap` (the named predicate replacing two byte-identical re-derivations in `state.cts`). `hasMilestoneSectioning(content)` is the sibling predicate answering "could a whole-document phase count conflate two different milestones?" — and since #3185 it is decided by milestone VOCABULARY, not by heading position: a heading is a milestone heading iff it is a non-Phase heading (level 1-3) carrying a version token, a shipped/active marker, or the word `Milestone`, and sectioning means two or more of them, since one section cannot conflate siblings. Since #3642, `buildStateFrontmatter`'s flat-vs-milestoned gate consumes the >=1 sibling `hasAnyMilestoneSection` over the same `countMilestoneHeadings` walk instead: asserted-vs-section is a different question than sibling conflation, and needs only ONE section to go wrong — with exactly one section and an asserted milestone absent from the ROADMAP, the whole-document count IS that foreign section's phases, so the #3354 withhold (stored value preserved + warning) governs; a zero-signal (genuinely flat) roadmap keeps the whole-document count per #2828. Three position-based models were tried and each shipped a defect — "any non-Phase heading" over-detects, so a flat ROADMAP carrying an ordinary `## Progress` was called sectioned and its declared phase count discarded for the on-disk directory count (#3204, #2828 regressing at 1.9.1 via #3184's own consolidation); strict nesting misses same-level siblings (regressing #1761) and false-positives on the bundled `templates/roadmap.md` shape, where a `## Phases` wrapper holds a single nested milestone; adjacency false-positives whenever a structural heading merely precedes a phase heading. Known limit: two milestone sections carrying none of the three signals are not detected. `extractCurrentMilestoneScoped` is the real extractor and returns the Planning Scope Module's `ScopedResult`; `extractCurrentMilestone` remains a one-line wrapper over `.value` because its blast radius is CRITICAL (200+ affected symbols, 20 direct callers) and its signature must not move. `getMilestonePhaseFilter` gains a `scope` field: its pass-all degrade is PRESERVED where its premise holds (a genuinely-empty, freshly-declared milestone reports `SCOPE.COMPLETE`) and is now labelled where it does not (`SCOPE.TRUNCATED` when the window reached no phase entries while the document has them), so the destructive consumer — `milestone.complete`, which MOVES phase directories — can refuse instead of archiving every phase directory on disk (#3166). The filter's function behavior is deliberately unchanged: making it deny-all on a non-COMPLETE scope would trade a silent over-inclusive answer for a silent under-inclusive one on the read paths that count with it. `findRoadmapProgressTable(content)` (#1956) locates the `## Progress` table — scoped to that heading via the markdown-sectionizer seam, falling back to the whole document for a headingless milestone slice — so a differently-headed table sharing the `Phase | Plans Complete | Status | Completed` columns cannot be read instead (the #2012 decoy class). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` expresses the same scope independently for the completion RATIO; the two are held in agreement by a parity test rather than by a shared call, because that symbol's blast radius does not justify a refactor. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).
### Core Utilities Module
Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (`toPosixPath`), filesystem scanning (`detectSubRepos`, `readSubdirectories`, `getPhaseFileStats`, `pathExistsInternal`), plan/summary pairing helpers (`countMatchedSummaries`, `findUnsummarizedPlans`, `findOrphanSummaries`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `extractCanonicalPlanId`, `timeAgo`). `filterPlanFiles`/`filterSummaryFiles` were retired by #3183 (ADR-3180 Decision 2) — `getPhaseFileStats` no longer re-derives plan/summary filename matching locally; it now sources `plans`/`summaries` (plus a `scope` field, `COMPLETE`/`TRUNCATED`/`UNREADABLE`) directly from `scanPhasePlans` (`src/plan-scan.cts`), the single owner of live-plan counting. Depends only on Node built-ins and already-leafed modules (`phase-id` for `comparePhaseNum`, `planning-workspace` for `findContextMdIn`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/core-utils.cjs` (generated from `src/core-utils.cts`).
### Agent Install Check Module
Module owning agent-presence resolution and verification, extracted from the Core module as the cleanup step that retired the `core.cjs` re-export spine (the final ADR-857 decomposition, epic #1267). Interface: `getAgentsDir(runtime?, projectRoot?)` — env-var-aware, runtime-aware agents-directory resolution; Claude resolves `__dirname`-relative unless that path contains a `node_modules` path segment, in which case it falls back to `getGlobalConfigDir('claude')/agents` (#3203); that segment test is lexical and case-sensitive rather than an install-shape guarantee — it targets the layouts where the sibling `agents/` is the package's own bundled copy and the check would otherwise validate the package against itself, and any path merely carrying a directory of that name resolves the same way. Other runtimes prefer a manifest-backed project-local agents directory before their global configuration home. The manifest gate is intentional: runtime-native project agents must not shadow a working global GSD install. `checkAgentsInstalled(...)` validates `gsd-file-manifest.json` completeness and confirms the declared agents exist on disk. Pure read/verify — no install-write side effects (writes remain the Installer Module's). Consumed by the Init Command Module, the verify workflow, and the docs workflow. Source of truth: `gsd-core/bin/lib/agent-install-check.cjs` (generated from `src/agent-install-check.cts`); replaced the two functions that squatted in `core.cts`. See Installer Module and ADR-857.
### Config Loader Module
Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record<string,unknown>` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Loading is **not side-effect-free by default**: normalizing a legacy key marks the config dirty and writes the migrated shape back to disk, which is how a legacy project is migrated by ordinary use. `options.persist: false` (#3648) suppresses exactly that write while leaving resolution identical — the option a caller passes when it is only ASKING the config something (the Git Query Module's protected-branch predicate runs on every `execute-phase` and `ship`, and a question must not rewrite the file it asks about). It is opt-OUT, so every pre-existing caller keeps the historical write-back. Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`).
### Planning Publication Gate (`planning.pr_strict`)
The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). A third gate is deliberately NOT derived from those declarations: `PLANNING_DELETIONS` (#3679) counts DELETED `.planning/` paths via `git diff --name-status --no-renames` and must be `0` in every mode — a deleted planning path is data loss, not filtering, and name-only counting cannot see status. The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`.
### Planning Commit Gate (`commit_docs`)
The seam deciding whether `.planning/` artifacts reach git. `commit_docs` resolves in the Config Loader Module (`loadConfigResolved`) through an ordered chain — an explicit value (top-level `commit_docs` or its `planning.commit_docs` alias) wins; absent that, `isGitIgnored(cwd, '.planning/')` auto-resolves it to `false`; otherwise the manifest default (`true`) applies. `cmdCommit` (Command Module) is the ONLY sanctioned writer: it returns the typed skip envelope `{ committed: false, skipped: true, hash: null, reason }` with `reason ∈ { 'skipped_commit_docs_false', 'skipped_gitignored', 'skipped_commit_docs_phase_false' }` rather than erroring, and `skipped: true` is explicit so agent prompts match a first-class success signal instead of inferring a skip from a missing `committed` and improvising a raw-git fallback (#3678). Those `reason` strings are a de facto public contract — `agents/gsd-executor.md` pattern-matches on them — so they are additive-only.
**Per-phase override (#3587, epic #2292 Phase 3).** A NEW tier resolves ABOVE the chain above, entirely inside `cmdCommit` (`src/commands.cts`'s `resolveCommitDocsPolicy`/`resolvePhaseCommitDocsOverride`) — deliberately NOT inside `loadConfigResolved`, which has no phase context and is called by nearly every command. The dynamic config key `phase_commit_docs.<phase-id>` (a `{ "<phase-id>": boolean }` map, registered in `config-schema.manifest.json`'s `dynamicKeyPatterns` and threaded through `config-loader.cts`'s `_baseConfig` projection the same way `agent_skills` is — a dynamic key absent from that hand-maintained allowlist is silently dropped on read, the exact failure mode `features.<feature_name>` demonstrates today) lets a tech lead commit one phase's artifacts while the project-wide `commit_docs` stays `false` (or the reverse). The phase being committed is resolved via the PRE-EXISTING `detectPhaseNumberFromFiles(files)` (the same #2539-hardened, project-code-aware derivation `branching_strategy` already used one branch below) and normalized through `normalizePhaseName` on both sides of the comparison, so `3`/`03`/`PROJ-03` hit one entry and a value scoped to a DIFFERENT phase never leaks. A non-boolean stored value (`"true"`, `1`, `null`) is never coerced — it falls through to the pre-existing chain untouched. When this tier suppresses a commit, the envelope's reason is `skipped_commit_docs_phase_false` — deliberately distinct from `skipped_commit_docs_false`, so a per-phase suppression is never reported as "your project setting is false" when it is actually `true`. **AC4 (byte-identical when unset):** with no `phase_commit_docs` key, this tier is a no-op and the three-tier chain above resolves exactly as before — pinned by the `folded:phase-commit-docs` block's C1-C5 in `tests/commit-docs-bypass.test.cjs`. The `phase_commit_docs.<phase-id>` grammar is a hand-copy of the canonical `PHASE_NUMBER_TOKEN_SOURCE` (`src/phase-id.cts`, #2128) into the hand-maintained schema manifest — pinned against drift by that same block's describe 'E' in `tests/commit-docs-bypass.test.cjs` (behavioral, over a shared shape list, per CLAUDE.md's Generative Fix Divergence class). **The two reasons are ordered, not peers, and `skipped_gitignored` is near-unreachable in a real project** (measured #3585): `cmdCommit` tests resolved `commit_docs` FIRST, and whenever `.planning/config.json` exists — which it does in every initialized project — the loader's gitignore auto-detect has already resolved that value to `false`, so the first branch returns `skipped_commit_docs_false` and the `isGitIgnored` branch below it is never reached. `skipped_gitignored` fires only when `config.json` is absent entirely, so the loader falls back to the `true` default and `cmdCommit`'s own check is what fires. A gitignore-driven skip therefore reports the config-driven reason; both members are behaviorally pinned by `tests/commit-docs-bypass.test.cjs` (B1-B3 and G1) so a rename fails loudly, but the reason a user sees does not distinguish *which* input suppressed the commit. **The gate is bypassable only from OUTSIDE the code**: a workflow step that types `git add` into its own shell reaches the index without passing through `cmdCommit`, and no code change can intercept that. Two guards therefore enforce it as text rather than at runtime: `tests/commit-files-pathspec.test.cjs` (#2269) requires every shipped `commit` invocation to declare `--files`, and `tests/commit-docs-bypass.test.cjs` (#1783, made repo-wide by #3585) requires every shipped `git add` able to reach `.planning/` to sit inside an **executable** `commit_docs` check — a markdown prose conditional ("**If `commit_docs` is true:**") is not a guard, because the bash block below it runs regardless. Both consume one shell tokenizer (`tests/helpers/shipped-command-scan.cjs`) and one exemption marker (`# gsd-scan-ignore: #NNN`, reason must cite a tracking ref per ADR-456). Guard state does not cross a fenced-block boundary: each fenced block is its own shell, so a guard opened in one block does not protect a `git add` in the next. Known limit: `.gitignore` has no effect on files git already TRACKS, so a project that committed `.planning/` before ignoring it keeps staging those paths — see `gsd-core/references/planning-config.md` and #3586. **`cmdCheckCommit`** (Command Module, verb `check-commit`) is a SEPARATE reader of the same `commit_docs` value, for callers outside GSD's own commit path: it inspects the staged set directly and refuses (non-zero exit) when `commit_docs` is `false` and any staged path is under `.planning/`; otherwise it allows. `gsd-tools commit-docs-guard enable`/`disable` (#3588) is the opt-in installer for a `.git/hooks/pre-commit` hook that shells out to exactly this verb, closing the one bypass the text-scan guards above cannot reach — a human or script running a bare `git add -A && git commit` in their OWN shell, outside any GSD-shipped workflow. The hook is identified by a `# gsd-core:commit-docs-guard` marker line (presence-checked, not byte-equality), is written only on explicit request (no install path wires it by default — locked by `tests/commands.test.cjs`'s E2 row), refuses rather than overwrite or delete a foreign `pre-commit`, resolves the real hooks dir via `git rev-parse --git-path hooks` so a linked worktree or submodule whose `.git` is a FILE works, and refuses outright when `core.hooksPath` is already set, because a written-but-ignored hook is worse than a refusal.
### Model Catalog Module
Leaf module owning the **static** model tables and the closed vocabularies derived from them — the tier/runtime/provider enums (`VALID_TIERS`, `VALID_AGENT_TIERS`, `KNOWN_RUNTIMES`, `KNOWN_PROVIDERS`, `RUNTIMES_WITH_REASONING_EFFORT`, `RUNTIMES_WITH_FAST_MODE`, `ADAPTIVE_TIER_VALUES`), the alias and profile maps (`MODEL_ALIAS_MAP`, `RUNTIME_PROFILE_MAP`, `PROVIDER_PRESETS`), effort rendering (`renderEffortForRuntime`), and the agent→model projections (`getAgentToModelMapForProfile`, `formatAgentToModelMapAsTable`). Also owns the per-model Codex effort capability table (`CODEX_MODEL_EFFORT`, sourced from `model-catalog.json`'s `codexModelEffort` with a `_baseline` fallback for unknown model ids) because Codex declares `supported_reasoning_levels` per model rather than per runtime, so `renderEffortForRuntime('codex', level, model)` resolves against that table. A **genuine leaf**: it imports `node:path` and its own `model-catalog.json` and nothing else, which is what makes it the correct home for anything several unrelated surfaces must agree on. Model *ids* live in `model-catalog.json`, never inline — changing one means regenerating goldens (`UPDATE_GOLDEN`). Also owns the **Anthropic-flavored-model rule** (#3241, ADR-2313): `CLAUDE_AGENT_ALIASES` (the frozen four-alias set `opus`/`sonnet`/`haiku`/`fable`) and `isAnthropicFlavoredModel(model)`, which is true for a bare tier alias or for any `claude-*` id in any provider namespacing (`anthropic/claude-*`, `us.anthropic.claude-*`); no OpenAI/Codex model id contains "claude", so the case-insensitive substring test is safe and exhaustive. It was **moved down here from the Model Resolver Module** rather than shared from there, because the Codex posture check (`agent-install-check`, epic #2313 Phase 2) and the Codex `.toml` sync (`commands`, Phase 3) both need the rule and neither may take the `config-loader` dependency `model-resolver` would have brought; `model-resolver` re-exports it for back-compat and a parity test fails if the two ever fork. _Avoid_: "the model list" (ambiguous between the catalog JSON and the derived enums). See Model Resolver Module, ADR-2313, and ADR-0003.
### Model Resolver Module
Module owning model and effort resolution policy: resolves the model, runtime tier, planning granularity, reasoning effort, and fast-mode for a given agent by reading project config and resolving against the model profiles and catalog (`resolveModelInternal`, `resolveModelPolicy`, `resolveTierEntry`, `resolveModelForTier`, `resolveGranularityInternal`, `resolveEffortInternal`, `resolveFastModeInternal`, `resolveEffortForTier`, `nextEffort`, `assertValidGranularityOverride`). Depends only on leaf modules (`config-loader` for `loadConfig`, `configuration` for defaults, `model-profiles` and `model-catalog` for the static tables) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2f (#888) — the final core.cts decomposition step; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. **`CLAUDE_AGENT_ALIASES` no longer lives here** — it moved down to the Model Catalog Module (#3241, ADR-2313 Phase 1) so the Agent Install Check and Codex-sync surfaces can consume the alias rule without taking a `config-loader` dependency this module would have dragged with it; it is still **re-exported** from here, so existing importers (`bin/install.js`, `tests/codex-config.test.cjs`) are unaffected and a parity test asserts both modules expose the same set. Source of truth: `gsd-core/bin/lib/model-resolver.cjs` (generated from `src/model-resolver.cts`).
### Install Model Override Resolver Module
Module owning install-time per-agent model-override resolution (#2256 / #2794), extracted from `bin/install.js`'s inline agent-staging loop (#2875 Part 2 / J8), which duplicated this EXACT precedence chain across two runtime branches (OpenCode/Kilo, ~24 lines each): `model_overrides[agent]` > `model_profile_overrides.<runtime>.<tier>` > omit. Interface: `readGsdGlobalModelOverrides`/`readGsdEffectiveModelOverrides`/`readGsdRuntimeProfileResolver` (impure config-file reads, mirroring `install-effort-resolver.cts`'s existing extraction precedent) and `resolveAgentModelOverride` (pure given their pre-resolved outputs). Reached from `runtime-artifact-layout.cts`'s agents-kind `stage()` for the opencode/kilo converters, so it is on the `installRuntimeArtifacts` call tree — every fs touch routes through `installFs()` (Install Fs Adapter Module) rather than calling `node:fs` directly, so a fake adapter injected via `withInstallFs` is honored. A single source of truth means the descriptor-driven agents pipeline and `bin/install.js`'s own callers can only diverge if this module changes, not silently across two hand-maintained copies (the Generative Fix Divergence class CLAUDE.md's "Known Defects" section warns about). Source: `src/install-model-override-resolver.cts` -> `gsd-core/bin/lib/install-model-override-resolver.cjs`. See Install Engine Module, Model Resolver Module. #4032 added a sibling read path, `readGsdEffectiveAgentTools`, resolving `agent_tools` (`*`/per-agent selectors, read from BOTH `~/.gsd/defaults.json` and the nearest `.planning/config.json`) with the same global-then-project precedence as `resolveAgentModelOverride` — a plain object-spread merge keyed by the SAME selector name in both files, project winning; there is no `project:<agent>` prefix syntax. Consumed pre-converter by `appendAgentTools` (Runtime Artifact Conversion Module) via `stageAgentsForRuntimeWithConverter` (`src/install-profiles.cts`).
### Codex Agent TOML Module
A **genuine leaf** (node builtins only) owning the typed IR for `~/.codex/agents/<agent>.toml` (#3243, ADR-2313 Phase 3). It is a **document model, not a policy** — it knows how to parse/render/strip the two keys the posture owns (`model`, `model_reasoning_effort`); it does NOT know which `model` values are illegal for Codex (that predicate, `isAnthropicFlavoredModel`, stays in the Model Catalog Module and the caller decides what to strip). `parseCodexAgentToml(content) → {ok:true,doc} | {ok:false,reason}` is the STRICT half: `PARSE_REASON.UNTERMINATED_BLOCK` when the `developer_instructions` block is opened but never closed, because a writer that proceeds on a malformed document risks rewriting it. `renderCodexAgentToml(doc)` round-trips **byte-identically** for an unmodified doc — the load-bearing property that stops a sync from silently reformatting a user's file — by keeping the original `lines` array (never re-derived) plus the detected `eol`/BOM/trailing-newline metadata, and rejoining rather than reconstructing. `stripModel`/`stripReasoningEffort` remove exactly one targeted line, re-indexing the block range and the sibling key's line index; every other line (comments, hand-added keys, the prompt block, line endings) is untouched. `scanTomlLines`/`stripBOM`/`findDeveloperInstructionsBlockRange`/`unquoteTomlValue` are the LENIENT reader primitives — moved here (not copied) from the Agent Install Check Module (#3242, Phase 2), which still imports and calls them directly, unchanged in behavior: an unterminated block falls back to "rest of file is inside the block" rather than failing, because misreading prompt prose as a pin is only a false positive. One block-range detector (`findDeveloperInstructionsBlockRange`, now carrying a `terminated` flag the strict parser reads and the lenient scanner ignores) serves both policies, so the reader and the writer can never silently diverge on where the block ends. Consumed by the Codex `.toml` sync (Commands Module's `cmdEffortSyncCodex`, ADR-2313 D7) and the Agent Install Check Module's `checkCodexModelPosture`. Source of truth: `gsd-core/bin/lib/codex-agent-toml.cjs` (generated from `src/codex-agent-toml.cts`). See Agent Install Check Module, Model Catalog Module, ADR-2313.
### Package Identity Module [Planned]
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/gsd-core`), `binName` (`Object.keys(.bin)[0]` → `gsd-core`), `repoSlug` (parsed from `.repository.url` → `open-gsd/gsd-core`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
`SEAM.package-identity.owns=GSD's published-package coordinates (packageName, binName, repoSlug, changelogRawUrl, manualInstallCommand) — single seam so a repoint/rename is a one-line change`
`SEAM.package-identity.enforced-by=test:tests/package-identity.test.cjs`
### Update Context Module [Planned]
Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home, cwd, env, fs, preferredConfigDir, preferredRuntime })` is a pure, injected-fs port of update.md's former ~280-line `get_installed_version` bash; it reproduces the full precedence cascade — preferred-config-dir fast path, local-over-global probe with same-path dedup, env-var overrides (`CLAUDE_CONFIG_DIR`, `OPENCODE_CONFIG`, `KILO_CONFIG`, `XDG_CONFIG_HOME`, `CODEX_HOME`, …), and semver validation — and returns the 4-field contract `{ installedVersion, scope, runtime, gsdDir }` (scope ∈ `LOCAL`/`GLOBAL`/`UNKNOWN`). Antigravity is modelled first-class (its `.gemini/antigravity{,-ide,-cli}` dirs probe before bare `.gemini`; #3608). Exposed to the workflow as `gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json`; `loadUpdateContext` wires the real fs. The workflow keeps only the execution_context path → `PREFERRED_*` derivation (the one input it alone knows). Source: `gsd-core/bin/lib/update-context.cjs`; tests: `tests/update-context.test.cjs`. See Installer Module and Package Identity Module.
### Skill Surface Budget Module
Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `gsd-core/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `gsd-core/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `gsd-core/bin/lib/clusters.cjs`; per-runtime state persists in `<runtimeConfigDir>/.gsd-surface.json` independent from the `.gsd-profile` marker. For a materializing mutation, `applySurface` accepts the candidate state in memory, stages every artifact kind before mutation, synchronizes the artifacts, and publishes or removes `.gsd-surface.json` last. A staging failure therefore leaves the prior artifacts and state untouched. See ADR-0011.
### Context Composer Module
Shared, pure, no-I/O seam owning priority-ordered composition of content fragments within a measured budget (ADR-1671 Decision item 2; extracted from `prompt-budget` by #2929, epic #1671 Phase 2). Interface: `composeWithinBudget({fragments, budget, measure, options}) → {fragments, metadata}`, plus the relocated `headShrink`/`tailTruncate` primitives. **The composer decides; the caller renders** — it returns a plan of surviving fragments with their trimmed content and never emits a rendered string, because `prompt-budget`'s assembly is prompt-shaped (`## Roadmap`, `### <file>`, note in position two) and per-runtime emission renders differently; owning rendering would prevent one seam from serving both. **Budget unit is injected** via `measure(text) → number` (`prompt-budget` passes `estimateTokens`, chars/4; emission passes a byte counter per ADR-1671's "bytes for emission caps"), with `charsPerUnit` as `measure`'s inverse for the proportional-truncate step — the prior code hardcoded `* 4`, which silently assumed the token estimator. **Shrink strategies, not a cutoff:** ADR-1671's literal contract says "priority + binary-search cutoff", which cannot express head-shrink, proportional-truncate-with-floor, or never-droppable classes; the closed strategy set is `verbatim | head-shrink | proportional-truncate(floorChars) | drop`, and a cutoff strategy joins that set when per-runtime emission needs it (Phases 3-4). Ordering is **declaration order**, not a numeric priority field. A `flexReserve`-style floor is a per-fragment guarantee, NOT a budget cap — it may deliberately push the total above the proportional share. The note reserve is deducted **only under pressure** (`baseline > effectiveBudget`, strict): deducting unconditionally drops sections `reserve` units early, which is the PR #3708 regression recorded at `LEARNING.prompt-budget.boundary-gap`. Presence is a **truthy** test, so an empty-string fragment is indistinguishable from an absent one — characterized, not designed. Source of truth: `gsd-core/bin/lib/context-composer.cjs` (generated from `src/context-composer.cts`). Test anchors: `tests/prompt-budget-parity.test.cjs`, `tests/fixtures/prompt-budget-parity/corpus.json`.
### Workflow Fragments Module
Pure, no-I/O seam owning in-file `<!-- gsd:section id="<id>" when="<when>" -->` / `<!-- /gsd:section -->` marker parsing and composition for GSD workflow markdown (ADR-1671 Decision item 1 + migration step 4 + open questions 1 & 2; epic #1671 Phase 3, #2930). `parseWorkflowSections` partitions a document into explicit (marked) and gap (unmarked, `explicit: false`) sections in document order — a marker line is removed in full (text + its own terminator), so an unmarked workflow (88 of 89 today) parses to exactly one implicit gap fragment and round-trips byte-identical. `toFragments` maps sections to Context Composer Module fragments, every one `{kind: 'verbatim'}` — non-lossiness in this phase is a structural guarantee of the strategy set, never a large-budget trick. `composeWorkflow` is the emission entry point: parse → `toFragments` → `composeWithinBudget` → `renderFragments`, run BEFORE the per-runtime converters so a marker attribute is stripped before any path-rewrite regex can reach it. **The grammar is deliberately CLOSED** (Greenspun's Tenth Rule): `when=` takes exactly one atom from the frozen `WHEN_VOCABULARY` — **29 atoms** (4 at #2930, widened 4→14 by #2992, 14→19 by #2993, 19→29 by #2994, each requiring a coordinated ADR-1671 amendment) — with no boolean operators, negation, or nesting; an unknown `when=` value throws rather than being silently dropped, and widening the vocabulary requires an ADR amendment, not an organic edit. Two gates govern admission: a named consuming section of at least 400 bytes, and a fact the init seam demonstrably computes at a real entry point — an atom failing the second evaluates `false` forever, so its marker looks like working gating while silently disabling itself. Any condition that cannot reduce to a single boolean is not an atom: a compound is resolved upstream in the FACT by the init seam (`state:chunked-mode`), never in the grammar, and negation is expressed as its own positively-phrased atom (`state:flat-mode`), never as an operator. Fence and HTML-comment interleaving is scanned in one left-to-right pass with two mutually exclusive states, reusing the discipline from the Context Predicates module's fence/comment scan (the two-pass design that caused #2928's silent-skip-to-EOF defect). `when=` was parsed and validated but not acted on at #2930; applicability selection shipped in Phase 5 (#2932, Section Manifest Module) and the rollout is complete across **15 workflows / 37 sections** (pilot `execute-phase.md` at #2930 — retargeted from `plan-phase.md`, which sat 36 B under the ADR-857 `PRE_PHASE6` gate and could not absorb marker overhead until #2993 fragmentized it; the remaining 13 LARGE/XL workflows at #2994). **Emission scope covers `agents/` as of #2995**, so a marker in an agent file is stripped at emit rather than shipped verbatim into every runtime — composition runs at two call sites (`stageAgentsForRuntimeWithConverter`, with `agentsKind`/`kimiAgentsKind` routed through it, and `bin/install.js`'s inline agent loop) plus `installCodexConfig`'s per-agent TOML read, always BEFORE any path rewrite. **`when=` GATING remains workflow-only**: `section-manifest.json` is keyed `{workflows: …}` and `gen-section-manifest.cjs` scans only `gsd-core/workflows/*.md`, so an agent atom has no consumer and would fail admission gate (2); agents are size-managed by extraction to `gsd-core/references/` per `DEFECT.AGENT-FILE-SIZE-CAP-BREACH` instead. Source of truth: `gsd-core/bin/lib/workflow-fragments.cjs` (generated from `src/workflow-fragments.cts`). Test anchors: `tests/workflow-fragments.test.cjs`, `tests/workflow-fragments.property.test.cjs`, `tests/workflow-fragments-emission.install.test.cjs`.
### Section Manifest Module
Pure, no-I/O `when=` evaluator over `InvocationFacts`, mapping a document-order list of parsed `gsd:section` sections (Workflow Fragments Module) to an included/excluded partition for one concrete invocation (ADR-1671 Decision items 3 & 4 + migration step 6; epic #1671 Phase 5, #2932). **The evaluator is a LOOKUP, not a parser** — `WHEN_PREDICATES` is a total map from each frozen `WHEN_VOCABULARY` entry (imported unchanged from `workflow-fragments.cjs`, never redeclared) to exactly one predicate over `InvocationFacts` — `{flags: ReadonlySet<string>, phaseNumber: string|null, hasPriorPhases: boolean}` plus optional already-resolved booleans (`needsCodebaseMap`, `phaseMvpMode`, `worktreesEnabled`, `chunkedMode`, `uiPhaseActive`, `fallowEnabled`, …), every field a plain value the caller computed before `selectSections` runs; `flags` is a `ReadonlySet` rather than a plain object because `.has()` carries no prototype hazard — and note `parseNamedArgs` NEVER returns `undefined` for an absent flag (booleans come back `false`, value keys `null`), so "present in the options record" is not token presence — as of ADR-3473 §8.4 / #3884, `parseNamedArgs(args, spec)` returns a `Result` (`{ok:true,data} | {ok:false,kind:'InvalidArgs',...}`, spec requiring an explicit `positionals: number|'rest'`), so this predicate now describes `.data`'s fields on the `ok:true` branch, not a bare returned object; the null/`false`-never-`undefined` guarantee on those fields is unchanged. It MUST NOT tokenize, split on operators, or interpret `when=` structure — the moment it parses, the ad-hoc language Greenspun's Tenth Rule warns against has begun. `selectSections(sections, facts)` returns `{included, excluded}` id arrays that together contain every input id exactly once, in the same relative document order, never mutating the input. An unrecognized `when=` value fails closed via a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN` — never silently excluded — matching the discipline Phase 3 already established for the same vocabulary at parse time. Every predicate treats an absent fact key as falsy without throwing, since the caller (the init CLI seam) may not always populate every field. A coordinated-change guard runs at module load: every `WHEN_VOCABULARY` entry must have exactly one predicate here, so a 5th vocabulary entry added without a matching predicate fails loudly at load time rather than silently falling through to `REASON.UNKNOWN_WHEN` only at run time. Selection output is generated ahead of time into the committed `gsd-core/workflows/section-manifest.json` (`scripts/gen-section-manifest.cjs`, reusing `parseWorkflowSections` unchanged — a second marker parser here would be the `DEFECT.GENERATIVE-FIX` divergence class) rather than derived from markers at run time, because markers are stripped at emit and the installed parent carries no `gsd:section` metadata. Source of truth: `gsd-core/bin/lib/section-manifest.cjs` (generated from `src/section-manifest.cts`). Test anchors: `tests/section-manifest.test.cjs`, `tests/section-manifest.property.test.cjs`, `tests/gen-section-manifest.test.cjs`.
### Runtime Artifact Layout Module
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `<configDir>/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. #2871 Phase 2 widens the Module from placement to placement **+** trigger resolution: `resolveTriggerSurface(runtime, scopes, { stems, routerStems?, childToRouters?, registry? }) -> TriggerSurface[]` answers "what does a user type" rather than "where does a file land" — a new, pure function alongside `resolveRuntimeArtifactLayout` (untouched, still 7 callers), never a widened signature. Only `commands` and `skills` are trigger-bearing; `agents`/`kimi-agents` are excluded entirely — an agent is invoked through the Agent/Task tool's `subagent_type`, a separate dispatch interface point, never a `/gsd-<name>` a user types (ADR-2866 amendment below). Each `TriggerSurface` names its `trigger`, `kind`, `scope`, `destPath` (computed through the SAME `namespacedByDir` branch `_copyStaged` uses), `registration` (`'direct'` | `'via-router'`, the latter naming the owning router's `routerTrigger` for a nested-router runtime's concrete child skill — #69), and `shadowedBy` (the winning sibling entry, or `null`). The winner across scopes and kinds is decided by scope rank first (Install Scope Module's `scopeRank`, consumed not re-derived — global outranks local) then by the runtime's new `runtime.triggerPrecedence` descriptor axis (ordered kind names, highest priority first; default `['skills', 'commands']`, required-with-default so a pre-#2871 `capability.json` keeps validating). `shadowedBy` ships unread this phase — Phase 4 (#2873) is its first consumer. See ADR-3660.
Issue #4132 keeps `resolveRuntimeArtifactLayout` as the single no-I/O placement resolver. Ordinary kind stage closures lazily select one complete provider shared by every source class the layout requires. Global installs provision canonical raw commands under `gsd-core/commands/gsd` and agents under `gsd-core/agents`, with manifest-owned refresh/removal; installer staging first uses that normal installed/marker selection and privately retries against the executing package only when source resolution fails before creating any staged output. Deployed Runtime Surface staging prefers the complete installation-owned corpus, then a complete independent legacy `.gsd-source` compatibility provider, and otherwise fails closed with an install/upgrade diagnostic. Provider independence and installed-corpus integrity are synchronous admission checks over the filesystem state observed during provider selection; same-user concurrent mutation after resolution remains outside this issue's threat model. No layout may mix commands and agents from different providers.
### Runtime Artifact Conversion Module
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs`. Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383). #4032 added `appendAgentTools` as step 3 of the ADR-1235 pre-converter pipeline (`stageAgentsForRuntimeWithConverter`, `src/install-profiles.cts` — not this module): appends `readGsdEffectiveAgentTools` grants (Install Model Override Resolver Module) into the canonical agent's `tools:` frontmatter — line-surgical, both inline-comma and YAML block-list forms — before the runtime-specific converter runs, so every converter (including Kilo's `mcp__*`→`{server}_{tool}` permission mapping and ZCode's `mcp__*` omission policy) sees configured grants without a duplicated per-runtime write path.
### Runtime Artifact Install Plan Module
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs`. See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
### Install Fs Adapter Module
Narrow, enumerated fs seam for the `installRuntimeArtifacts` call tree (`src/install-engine.cts`) — lands ADR-58's never-shipped `cleanup` rollout step (`registry → adapter → helpers → cleanup`, #2874, epic #2866 Phase 5). `installRuntimeArtifacts` now returns the executed plan it ran (`{ runtime, scope, kinds: [{kind, sourceDir, destDir, preserved}], cleanup: [{dir, ok}], postSteps }`) instead of `void`, including on the `combinedFamilyInstall` (OpenCode/Kilo) early-return path — no path may return `undefined` after this phase. Failure is unchanged: stage/rewrite errors still throw rather than becoming an `ok:false` value, so a caller cannot read success-shaped data off a failure path. Delivery is an ambient single mutable adapter (`current`), swapped for the duration of one synchronous install via `withInstallFs(deps.fs, fn)` and always restored in a `finally` — a `deps` parameter threaded through every function on the call tree (`install-profiles.cts`, `runtime-artifact-conversion.cts`, `commonjs-marker.cts`, `installer-migrations.cts`'s two reachable entry points) was rejected as a dozen+-site touch for no behavioral gain over the ambient swap, extending rather than replacing `createRuntimeArtifactInstallPlan`'s existing `deps` bag precedent (Runtime Artifact Install Plan Module). An injected `deps.fs` is a PARTIAL adapter merged over real `node:fs`; any method it omits — except `realpathSync`, the one method documented to degrade gracefully — now THROWS immediately if actually called, naming the missing method, rather than silently resolving to the real filesystem (#2875 defect fix: the prior silent fall-through let a fake adapter missing e.g. `rmSync` perform real destructive IO unnoticed). **Routes destination IO only, by design**: every write/probe against the install destination (copies, removals, snapshot/restore of preserved skill dirs, the manifest read) is fake-able; locating this package's own source tree (`findInstallSourceRoot`/`findAgentsSourceRoot`'s walk-up-from-`__dirname`, `readGsdCommandNames`) stays real and unrouted — a destination-fake's store starts empty and was never seeded with the repo's own paths, so routing that lookup would make every fake-adapter install throw instead of staging. The symlink-escape guard (`hasExistingSymlinkBetween`) and `assertDestWithinConfigHome` keep their REFUSAL DECISIONS outside this seam — only their `existsSync`/`lstatSync`/`realpathSync` probes route through it, so an injected fake can change what a probe observes but never flip the security decision itself. Writes remain byte-identical to pre-#2874 (AC4/AC5); existing `void`-ignoring callers (`bin/install.js`) are unaffected. Source: `gsd-core/bin/lib/install-fs-adapter.cjs` (generated from `src/install-fs-adapter.cts`). See Runtime Artifact Install Plan Module, ADR-58.
### Real Home Guard Module
Refuses an in-process install whose destination would land in the developer's REAL home while a Node test runner is in scope (#3712, ADR-1239/#2088 territory). Interface: `assertTestHomeSandboxed(operation, runtime, kinds, deps?)` (throws), `isTestHomeGuardRefusal(err)`, `SANDBOX_MARKER`. Exists because a runtime kind may declare a global `home` override resolved from `os.homedir()` rather than from the caller's `configDir` — codex's skills kind (`home: ".agents"`) and, since #3738, antigravity's global skills AND agents kinds (`home: ".gemini/config"`, the dir AGY scans for global discovery; configHome stays `~/.gemini/antigravity` for settings/runtime files) are the live cases — so sandboxing `configDir`/`targetDir` does NOT contain it, and `assertDestWithinConfigHome` (Runtime Artifact Install Plan Module) structurally cannot see the class: that gate confines a `destSubpath` to whatever root it is handed, and here the root IS the escaped home. The observed failure was silent destruction — a test file that sandboxed only `targetDir` pruned all 71 `gsd-*` skills from a real `~/.agents/skills` via `_removeGsdEntries` while the suite exited 0 and the manifest still reported a healthy install. **Six writers** resolve a kind `home` and then write or destroy under it and therefore carry a call: `installRuntimeArtifacts`, `uninstallRuntimeArtifacts` (Install Engine Module), `applySurface` (Surface Module), and `migrateLegacyDevPreferencesToSkill` — which CREATES rather than prunes, which is why it was missed on the first pass, and which runs from `_runLegacyInstallMigrations` BEFORE `installRuntimeArtifacts`' own assertion, so it guards the destination it already resolved rather than resolving a second time (generative-fix divergence) — plus `installOpencodeFamilySkills` and `installAgentsKindStandalone`, guarded against a future descriptor change rather than a present escape since no combined-family or agents kind declares a `home` today. Each of the four reachable writers carries an optional `deps: { os?, env? }` tail parameter — the guard's trigger condition is "HOME equals the passwd home", which cannot be simulated without pointing at the developer's real home — so `tests/install-write-confinement.test.cjs` drives the REAL entrypoint for each one and deleting any guard call site turns a row red. `migrateLegacyDevPreferencesToSkill` guards only when `target.hasHomeOverride` — that same resolution's own answer to "did the skills kind declare a `home`?", never inferred from `installRoot !== targetDir`, which is FALSE whenever the override resolves onto `targetDir` itself (a configDir of `$HOME/.agents`, exactly where codex's override points); both directions of that condition are pinned, so widening it to refuse ordinary confined migrations fails a row too. **Gated on `NODE_TEST_CONTEXT`** (set by `node --test`), never on `GSD_TEST_MODE` — several in-process test files, including the one that caused #3712, never set the latter — so ordinary installs outside a Node test context are untouched and codex still installs to `$HOME/.agents` normally. **The predicate asks about the DESTINATION, not about HOME state**, and about the path the write RESOLVES to rather than how it is spelled: a stale layout captured before `sandboxHome()` still names the real `~/.agents` while HOME is sandboxed, so a HOME-state check would wave it through, and an aliased `<sandbox>/.agents` symlink or junction would otherwise redirect an allowed path into the real home. Canonicalization itself fails CLOSED: a component that cannot be resolved (`EACCES`/`EPERM` on a directory whose mode changed, `ELOOP` on a symlink cycle, `EIO` on a failing mount) is REFUSED rather than falling back to the lexical spelling — that fallback was a second, unnamed fail-open, and precisely the ALLOW an unresolvable alias needs, since the sandbox spelling then satisfies the nested-sandbox exemption (Codex review of #3725). Only `ENOENT`/`ENOTDIR` walk up, matching `identify`'s own errno split rather than inventing a second one: both mean "no such path as named", which is the ordinary shape of a destination no install has created yet. A destination inside the real home is exempted only when all three hold — HOME differs from the passwd home by filesystem identity, the passwd home is not itself beneath that HOME (`/Users`, `C:\Users`), and the destination resolves beneath it. That exemption is not a softening: on Windows `os.tmpdir()` is under `%USERPROFILE%`, so EVERY test sandbox is a descendant of the real home and containment alone cannot separate the safe case from the dangerous one — without it the whole Windows matrix refuses. Every containment decision compares by filesystem identity (`st_dev`+`st_ino`), never pathname (the one pathname comparison in the module is `sameDirectory`'s equality shortcut on the marker branch, described below): `path.resolve` resolves neither symlinks nor case and `realpath` returns a canonical pathname two routes to one directory can disagree on. **Fails CLOSED**, with one named exception: where no passwd entry is readable (some CI images) it falls back to a path-valued marker set by `sandboxHome()`/`installSpawnEnv()` in `tests/helpers.cjs`, which must identify, must equal the home in effect, AND must contain every resolved destination. All three are load-bearing: matching HOME attests that a caller sandboxed HOME and says nothing about where these destinations resolve, so on its own the marker waved through the very stale-layout shape the primary branch refuses; and `sameDirectory`'s pathname-equality shortcut can answer yes for two identical UNidentifiable paths, which is not enough to place a destination against. It remains a deliberate weakening, since nothing on such a host can contradict a marker naming the real home. `installSpawnEnv` re-points the marker at an explicitly overridden HOME, so a spawn that supplies its own sandbox is not refused by a marker still naming the helper's default one. Refusals are STAMPED so `bin/install.js` can rethrow them without running `_codexPreConfigRollback()`, which deletes and recreates every snapshotted `gsd-*` directory in the resolved skills root — otherwise the guard's own refusal would provoke the mutation it exists to prevent. **Known limits, stated rather than implied**: a subordinate bind mount of the real `~/.agents` into the sandbox is not closed (a bind mount is not a link, so realpath keeps the mount-point spelling; closing it needs non-portable mount-table introspection), and a cross-process TOCTOU swap between check and write is out of reach. Source: `gsd-core/bin/lib/real-home-guard.cjs` (generated from `src/real-home-guard.cts`). See Install Engine Module, Surface Module, Runtime Artifact Install Plan Module, Runtime Artifact Layout Module.
### User Artifact Staging Module
Durable, on-disk staging for `USER_OWNED_ARTIFACTS` (Install Engine Module's `preserveUserArtifacts`/`restoreUserArtifacts` callers) across the preserve → wipe → restore window, closing #1874-F19: an in-memory-only `Map` held across a wipe is silently discarded on process death (Ctrl-C, OOM, a converter throw mid-copy), losing the user's file permanently (#2875, epic #2866 Phase 6, governed by ADR-3574). Interface: `stageUserArtifacts(destDir, fileNames, stagingRoot) -> StagedUserArtifacts`, `restoreStagedUserArtifacts(destDir, staged)`, `discardStagedUserArtifacts(staged)`, `recoverOrphanedUserArtifacts(stagingRoot, configDir) -> RecoveryResult` — four operations rather than two because call sites genuinely differ (one defers to a migration helper instead of restoring; another restores only on migration FAILURE). Synchronous only, every fs call routed through `installFs()` (Install Fs Adapter Module), which now GUARDS a partial injected adapter: any method the partial omits — except the one documented degrade-to-real-fs exception, `realpathSync` — throws immediately if actually called, instead of silently falling through to real fs (#2875 defect fix). Staging layout is fixed by convention — `<configDir>/.gsd-staging/user-artifacts/<sha256(destDir)-16>/{record.json,files/<name>}` — a sibling of every wipe target this phase's four call sites wipe, so staging survives all of them while resolving inside `configDir`; `record.json` is written AFTER every file copy lands, never before, so a half-written staging directory (crash mid-copy) has no record and is never mistaken for a complete one. All staged/restored/recovered names are FLAT (no path separator of either platform's flavor) — matching every real caller's actual usage and rejected the same way traversal/NUL-byte names already were. **Durability alone is not the fix**: a staged copy nothing ever reads back is bytes-safe but user-visibly lost — the #1879-F15 inert-fix failure mode — so `recoverOrphanedUserArtifacts` is wired at the START of `bin/install.js`'s `install()` and `uninstall()`, before the ordinary preserve step, for every runtime; this is the only production entry point that makes recovery reachable rather than merely callable. Its "never throws" contract is enforced with a per-file try/catch (one bad name is reported via `skipped` and the batch continues) wrapped in a per-entry try/catch (one bad batch is reported and the next staging entry is still attempted) — an earlier version left `mkdirSync`/the symlink-safe copy/the final cleanup `rmSync` unguarded, so a single unrecoverable entry (e.g. a directory unexpectedly staged where a file was expected, or `symlinkSync` throwing `EPERM` for an unprivileged Windows user) threw out of the function entirely — before that entry was ever cleaned up — permanently bricking every future install/uninstall (#2875 defect fix). **Carries NO policy** (same discipline as the Install Fs Adapter Module): every path this module writes, and every path recovery reads OUT of an on-disk record before writing to it (attacker-influenceable the moment an install runs on a shared machine), is re-resolved through the SAME `assertDestWithinConfigHome` (Runtime Artifact Install Plan Module) every other write on this call tree uses, THEN through the SAME `hasExistingSymlinkBetween` (Install Engine Module) `_copyStaged`/`migrateLegacyDevPreferencesToSkill` apply to their own writes — never reimplemented, and required lazily (call-time, not module-load-time) specifically to avoid a real circular require with Install Engine Module, which imports this module statically. Lexical confinement (`assertDestWithinConfigHome`) alone cannot see a symlinked ANCESTOR directory between `configDir` and a recorded `destDir`; the `hasExistingSymlinkBetween` re-check closes that gap (#2875 defect fix). `recoverOrphanedUserArtifacts` takes `configDir` as a REQUIRED, EXPLICIT parameter — it is never derived from `stagingRoot`'s own path shape, which would rest the confinement guarantee on a naming convention rather than an explicit caller-supplied value. Never overwrites something already present at the recovered destination, decided by `lstatSync` rather than `existsSync` — `existsSync` FOLLOWS symlinks and reports `false` for a DANGLING one, so it cannot see a dangling symlink an attacker planted at the destination to redirect the eventual `copyFileSync`/`symlinkSync` outside `configDir`; `restoreStagedUserArtifacts` applies the same `lstatSync`-based refusal before writing (#2875 defect fix — both were previously `existsSync`-based). Symlink-safe: staged files copy via Installer Migration Module's `copyPreservingSymlink` (itself newly routed through `installFs()` this phase, all five of its fs calls), which never dereferences a symlink — a managed path replaced by a link to (e.g.) `~/.ssh/id_rsa` cannot have the referent's bytes copied into the staging tree or back out of it; a consumer that reads a staged copy's CONTENT back (rather than re-copying it) must separately check for a staged symlink before `readFileSync`, or it will follow the link and read the referent (Install Engine Module's `_runLegacyInstallMigrations` applies this guard). Staging failure is a HARD throw (not swallowed) so a caller cannot proceed to wipe the source directory having staged nothing — worse than no staging at all; recovery and restore/discard degrade instead (missing files, missing staging root, malformed records are all "nothing to do", never a crash). `stagingRoot` confinement against `configDir`, and the symlinked-staging-root refusal (`hasExistingSymlinkBetween`), are both call-site responsibilities (Install Engine Module's `_resolveUserArtifactStagingRoot`, mirrored locally in `bin/install.js`) — this module accepts no `configDir` parameter to `stageUserArtifacts` and cannot perform that outer check itself. **Known limitation, documented rather than closed**: concurrent installs targeting the SAME `destDir` are not safe against each other — the staging key is `sha256(destDir)`, and both the stage-time entryDir-clear and the recovery-time end-of-batch cleanup unconditionally `rmSync` an `entryDir` they did not necessarily create, so two processes racing the same `destDir` can have one wipe the other's in-flight or just-committed batch; closing this fully needs either a cross-process lock (its own crash-safety design surface) or a guarantee installs never run concurrently against one `configDir`, neither of which this module can decide unilaterally. Explicitly out of scope: fsync durability (crash-safe against process death only, not power loss); routing the raw-`fs` uninstall wipe at Install Engine Module's `_runLegacyUninstallCleanup` (only the staging call itself routes through `installFs()` there — the surrounding wipe stays unrouted, matching Phase 5's deliberate exclusion of the uninstall tree). Source: `gsd-core/bin/lib/user-artifact-staging.cjs` (generated from `src/user-artifact-staging.cts`). See Install Engine Module, Install Fs Adapter Module, Runtime Artifact Install Plan Module, Installer Migration Module, ADR-3574.
### Install Scope Module
Owns the two-value install-scope axis (`'global' | 'local'`) as a typed value, replacing the bare `isGlobal ? 'global' : 'local'` string re-derived at 12 sites in `bin/install.js` plus several downstream re-derivations (#2870, ADR-2866). Interface: `resolveScope({ id, runtime, explicitDir?, env?, home?, existsSync? }) -> { id, configHome, settingsFile, consentRequired, hostPrecedenceRank }` — pure (no writes, never mutates `input`) and the returned value is frozen so a caller cannot corrupt a subsequent resolution. Owns the `InstallScope` type name: previously a private, non-exported `TypeAlias` inside Runtime Artifact Install Plan Module; that module now `import type`s it from here instead of re-declaring it, so the codebase does not grow a fifth spelling of the axis alongside the layout module's `'local' | 'global'`, `capability-lifecycle.cts`'s `'global' | 'project'`, and `capability-consent.cts`'s single `'project'` literal. `configHome` for `global` composes `resolveConfigHomeFromDescriptor` (Runtime Homes Module) unmodified rather than adding a `scope` parameter to it — that function is CRITICAL blast radius (60 dependents across 13 files); for `local` it joins the capability registry's per-runtime `localConfigDir` onto the real process cwd (the project you are standing in — not injectable via `home`, by design). `explicitDir` short-circuits both scopes identically to `getGlobalConfigDir`'s existing override, and every returned `configHome` is normalized to forward slashes UNCONDITIONALLY (`.replace(/\\/g,'/')`, never gated on `path.sep`). `settingsFile` reads the registry's `hostBehaviors.settingsFileByScope[id]` and is `null` for the 18 of 19 registered runtimes that declare none — absence is a value, not an invented Claude-shaped default; the one caller that legitimately wants a Claude fallback (`bin/install.js:550`) still applies it itself. `consentRequired` is `false` for `global` (nothing is recorded — matches Capability Registry Overlay's rule that a GLOBAL-scope capability is trusted without a consent record) and `true` for `local`; it reports the requirement only; it does not perform or waive consent. `hostPrecedenceRank` (`global` outranks `local`) is carried as data only this phase — unread until Phase 2 (#2871) defines precedence semantics. Throws `TypeError` for an invalid `id` (wrong case, empty, missing, or any non-string value — never coerced), an unknown `runtime`, or a runtime whose `configHome.kind === 'none'` (vscode — non-installable, #2103) — all three share one `instanceof TypeError` catch shape with Runtime Artifact Layout Module's existing unknown-runtime contract. **The `local`/`project` boundary is documented, not unified:** this module's `'local'` spelling — chosen because it is the CLI's own vocabulary (`--local`) and what the layout module and manifest already use — is deliberately NOT reconciled with Capability Consent Store's `ConsentRecord.scope: 'project'` or Capability Lifecycle's `'global' | 'project'` operations. `ConsentRecord.scope` is a value persisted on disk in user-owned consent records outside any repository; renaming that literal to match would silently invalidate every existing project-scoped consent record on a user's machine the next time it is read back — a far worse defect than the vocabulary split. The mapping instead lives here as a fact: install scope `'local'` ⇄ consent scope `'project'`; install scope `'global'` ⇄ no consent record at all. Source: `gsd-core/bin/lib/install-scope.cjs` (generated from `src/install-scope.cts`). See Runtime Homes Module, Runtime Artifact Layout Module, Runtime Artifact Install Plan Module, Capability Consent Store, Capability Lifecycle.
### Installed Surface Resolver Module
Read-only Module answering *"which GSD surfaces are installed, at which scopes, for which runtimes — and is one shadowing another?"* (#2872, ADR-2866 Phase 3). Interface: `resolveInstalledSurfaces(runtime?, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> InstalledRuntimeSurface[]`, each carrying both scope records (`{ scope, configHome, installed, manifestVersion, declaredRuntime, declaredScope, declaredScopeMatchesProbe, declaredRuntimeMatchesProbe, stems }`) plus a `triggers: TriggerSurface[]` resolved in ONE `resolveTriggerSurface` call over **only the scopes actually installed** — which is what makes `shadowedBy` describe this machine rather than a hypothetical. **The first code path in the repo that reads both install scopes at once.** Mutates nothing; builds fresh objects per call; caches nothing. It composes rather than re-derives: `resolveScope` (Install Scope Module) supplies each scope's `configHome`, `readInstallManifest` (Installer Migration Module) supplies presence, and `resolveTriggerSurface` + the exported `isNamespacedByDir`/`composeCommandFilename` (Runtime Artifact Layout Module) supply the trigger surface — the stem derivation is the *inverse* of that filename composition and consumes those exports rather than becoming a fourth copy of the rule (`fast-check` round-trip property guards the bijection). **Installed-ness is decided by manifest PRESENCE, never by the schema-2 `runtime`/`scope` fields**, which is what keeps a pre-#2872 (schema-1) install fully functional with no reinstall; the recorded fields are corroboration, surfaced as `declaredScopeMatchesProbe` / `declaredRuntimeMatchesProbe` so a manifest that disagrees with the directory it was found in (an `--config-dir` install, a copied config tree) is **reported, never silently corrected**. A runtime whose `resolveScope` throws (unknown, or `configHome.kind === 'none'` — vscode) propagates the `TypeError` when asked for explicitly but is *skipped* in the all-runtimes sweep, so one non-installable runtime cannot kill the sweep. Two scopes resolving to one `configHome` are deduplicated before the trigger call, so a single physical install is never reported as shadowing itself. **The `local`-scope manifest read is `lstat`-guarded (#2873)**: it refuses to follow a symlinked config dir or a symlinked manifest file, degrading that scope to `installed: false` — the same degraded verdict the pre-existing EACCES path already produces — rather than resolving a manifest outside the project the caller asked about; the `global` scope carries no such guard (it resolves against the real machine home, never an arbitrary cloned repository). Phase 4 (#2873, Install Shadow Report Module) is its first consumer, projecting `shadowedBy` into the install-time report and the `/gsd-health` W028 diagnostic. Source: `gsd-core/bin/lib/installed-surface-resolver.cjs` (generated from `src/installed-surface-resolver.cts`). See Install Scope Module, Runtime Artifact Layout Module, Installer Migration Module, Agent Install Check Module, Install Shadow Report Module.
### Install Shadow Report Module
Read-only projection Module answering *"is a cross-scope trigger collision hiding a spec tree from the user right now, and if so, which scope wins?"* (#2873, epic #2866 Phase 4, resolving #2218). Composes Installed Surface Resolver Module's `resolveInstalledSurfaces` rather than re-deriving install state — the design doc's "Rejected" list explicitly refuses to widen that module with a second export, keeping rendering/sanitization a separate concern with a separate consumer set (installer + `/gsd-health`). Interface: `buildShadowReport(runtime, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> ShadowReport` (a typed IR, never rendered text — tests assert the IR, per CONTRIBUTING's ban on raw-text-matching test outputs) plus `renderShadowReport(report) -> string[]`, the sole renderer consumed identically by the installer and the W028 health rule so install-time output and `/gsd-health` output can never drift. **Per-scope truth filter**: `resolveTriggerSurface` (Runtime Artifact Layout Module) synthesizes a candidate trigger for every stem at every installed scope's trigger-bearing kind, regardless of whether that specific scope's own manifest shipped that stem — left unfiltered this would over-report triggers as shadowed when no artifact exists at one side. This Module filters `shadowedBy` groups down to triggers whose underlying stem is present in BOTH scopes' own real `stems` list before reporting. **`kindsDiffer` distinguishes vanish from override**: for claude (`skills`@global vs `commands`@local) the kinds differ, so the losing side's entire spec tree becomes unreachable through the trigger (#2218's exact failure); for the 12 both-scopes-`skills` runtimes the kinds are the same on both sides, so the loser is merely overridden, not vanished — `renderShadowReport`'s wording is gated on this flag so it never claims a same-kind local tree "disappears". **Report, don't correct**: a declared runtime/scope that disagrees with the probed one is surfaced via `mismatches`, never silently absorbed or substituted (Postel's Law, liberal-but-visible). **Sanitized at the render seam**: `declaredRuntime` is attacker-influenceable (sourced from a manifest that may live inside a merely-cloned repository) and is length-bounded (64) but deliberately not charset-gated at the reader (`declaredRuntimeMatchesProbe` needs the raw value); `sanitizeForRender` strips ANSI escapes, C0/C1 controls, and Unicode bidi overrides/isolates before render, and never truncates a second time. **Degrades to `reason: RESOLVER_UNAVAILABLE`** (renders `[]`) for a runtime whose `configHome.kind === 'none'` (vscode) rather than propagating the resolver's `TypeError` — a shadow report must never crash an install or `/gsd-health`. Consumers: the installer (prints the report after `writeManifest`, exit code unchanged — a shadowed install is a warning per ADR-2866 Consequences, never a failure) and `health-diagnostic-rules/install-surface-shadowing.cts` (W028, WARNING severity, ADVISE remedy with `risk: NONE` — never auto-fixable, there is no single correct scope to remove). Source of truth: `gsd-core/bin/lib/install-shadow-report.cjs` (generated from `src/install-shadow-report.cts`). See Installed Surface Resolver Module, Runtime Artifact Layout Module, Runtime Artifact Conversion Module (spec-root reachability, the sibling deliverable in the same phase).
### Command Roster Module
Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state.
### Runtime Install Policy Module
Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58.
### Capability [Planned]
A bundle delivering one optional GSD feature, toggled as a unit at install or after install. Owns its skills, agents, hooks, federated config-key schema (keys + defaults + validation), and loop extension-point registrations, plus a `requires` list of other Capabilities, plus an optional `activationKey` (a dotted config key, e.g. `graphify.enabled`) naming the config toggle that gates the whole capability — consumed by the Capability State Resolver's per-capability `active` (absent → no config gate; see Capability State Resolver tri-state deepening). Declared co-located in the Capability's own folder and compiled into a generated central Capability Registry at build time. The five-step loop (Discuss → Plan → Execute → Verify → Ship) and shared-infrastructure skills (phase, config, help, update, surface, progress) are the privileged host, not Capabilities, in v1 — but host extension points are data so a loop step can become a Capability under a future uniform kernel. Supersedes the implicit feature-scattering across clusters, install-profiles, and config-schema. Generalizes the Skill Surface Budget Module and Runtime Install Policy Module.
### Loop Host Contract
Generated description of what the five-step loop (Discuss → Plan → Execute → Verify → Ship) exposes as extension points: per-step loop points, agent roles, and core artifacts. Sourced from structured `<!-- gsd:loop-host ... -->` HTML-comment markers embedded near the top of each of the five step workflow files (`discuss-phase.md`, `plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`). Generated by `scripts/gen-loop-host-contract.cjs` → `gsd-core/bin/lib/loop-host-contract.cjs` (ADR-894 §3 phase 3a-impl-2). Covers exactly the 12 canonical points (discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post). The generator enforces a drift guard: every declared non-orchestrator agent role must correspond to an actual agent reference in the workflow file. Consumed by `gen-capability-registry.cjs` (replaces the former inline `LOOP_HOST_CONTRACT` constant). Run `node scripts/gen-loop-host-contract.cjs --write` after editing a workflow step marker.
### Gate Predicate Evaluator Module
Pure, deps-injected evaluator for capability gate `check.predicate` blocks (#2008, ADR-2008). Prior to #2008 the registry validator accepted `check.predicate` (one of exactly-one-of `query`/`predicate`/`agentVerdict`) and the loop-resolver rendered it, but nothing EVALUATED a declared predicate — only `check.query` was enforced (dispatched via `gsd_run check <query>`), and built-in gates like `security` worked only via hard-coded `capId` prose branches in `ship.md`/`execute-phase.md`/`verify-work.md`. This module is the generic evaluation path: `evaluatePredicate(predicate, context, deps) → { block, message, details? }` dispatches by `predicate.kind` through a `KIND_TABLE`. Built-in kinds: `command-exit-zero` runs a declared command in a bounded `sh -c` subprocess (production binding: `shell-command-projection.execTool`) at the project root, inheriting env; exit 0 ⇒ pass, non-zero ⇒ block, timeout (SIGTERM) ⇒ block; `artifact-frontmatter-equals` resolves a phase or project-level Markdown artifact through injected dependencies and compares a frontmatter field by scalar string value. Command predicates interpolate `${PHASE_NUMBER}`/`${PHASE_DIR}`/`${PHASE_REQ_IDS}` from gate context. A THROWN error (malformed predicate, non-positive/non-finite timeout, command >4096 chars, missing dependencies, unknown kind) maps at the CLI seam to a non-zero check-command exit, which the workflow's two-step gate contract treats as a step-1 command failure routed per `onError` — so an evaluator bug is never conflated with a legitimate block decision. Leaf pure module (no fs/child_process/config — subprocess and artifact seams injected). CLI entry: `gsd_run check predicate --predicate '<json>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw`, wired into `check-command-router.cts:cmdCheckPredicate`; the four generic workflow gate-dispatch sites (`execute:wave:post`, `execute:post`, `plan:post`, and `ship:pre` — the last wired by #3559, which had resolved gates generically but enforced only two hardcoded `capId`s) branch on `check` shape (`query` vs `predicate`). Source of truth: `src/gate-predicate-evaluator.cts`. Docs: `docs/reference/gate-predicates.md`, `docs/how-to/command-exit-zero-gate.md`.
### Capability Registry
Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. Each feature capability's entry in `capabilities` now includes the optional `activationKey` field (the dotted config key that gates the whole capability, e.g. `"graphify.enabled"`; absent means no config gate). ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. ADR-857 phase 4a adds two derived views: `capabilityClusters` (`{ <capId>: [<skill stems>] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ <capId>: { tier, profiles: [...] } }` — the tier-derived index: suffix of `PROFILE_RANK` starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty `skills` array). The generator enforces a HARD gate (throws) if a capId matching a `CLUSTERS` key has a mismatched skill set, and emits SOFT `⚠ pending-reconciliation` warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. `install` and `surface` are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities/<id>/capability.json`.
### Federated Config
ADR-857 phase 3b seam that merges capability-declared config slices into the `loadConfig` return value. Implemented in `src/federated-config.cts` → `gsd-core/bin/lib/federated-config.cjs`. Exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }`. Rules: central-schema keys are skipped with a `pending-migration` warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal `__proto__`/`constructor`/`prototype` key checks. ADR-857 phase 6 made the channel live for migrated Capability keys: `config-schema.cjs` exposes `isCentralConfigKey()` for central ownership and `isValidConfigKey()` accepts central + runtime + dynamic + Capability-owned registry keys. `loadConfig` exposes `_setFederatedRegistryForTests`/`_resetFederatedRegistryForTests` seams for injecting a synthetic registry in tests.
### Capability Registry Overlay
Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that composes the frozen first-party Capability Registry (`capability-registry.cjs`) with a validated installed overlay of third-party capability manifests discovered at load time. Install roots are global (`$GSD_HOME/.gsd/capabilities/<id>/capability.json`, where `GSD_HOME` defaults to `~`) and project (`<projectRoot>/.gsd/capabilities/<id>/capability.json`). Primary interface: `loadRegistry({ includeInstalled }) → registry` — when `includeInstalled` is true the overlay is merged via the canonical `buildRegistry` so all derived views (bySkill, byAgent, byLoopPoint, configKeys) cover first-party and overlay entries identically. First-party always wins: any overlay entry whose id, owned skill/agent stem, or federated config key collides with first-party, or whose id uses a reserved `gsd-`/`gsd-core-`/`anthropic-` prefix, is rejected at load time. Load-time re-gate: an overlay failing schema validation or whose `engines.gsd` semver range does not satisfy the running GSD version is skipped with a warning and never crashes the load loop. Per-hook-kind policy: a skipped capability that declared a `gate`-kind hook now fails OPEN (#2009) — the loop resolver injects no gate and instead emits a loud warning naming the load-failure reason and the exact `gsd capability remove <id>` remediation, and the loop proceeds (the loader still records `_overlay.blockedGates`; only the consequence changed from block to warn); skipped `step` or `contribution` capabilities skip open. A capability dir whose co-located ledger entry carries an in-flight `_pending` intent (a crashed/uncommitted install or upgrade, ADR-1244 Phase 4) is skipped OPEN (never activated until reconciliation commits or rolls it back). #1459 user-owned consent gate: a PROJECT-scope overlay is activated (declarative surfaces AND command dispatch) ONLY when the user-owned Capability Consent Store holds a record for `(realpath(projectRoot), id)` whose stored `contentHash` equals the bundle content hash the loader RECOMPUTES at load (`bundleContentHash(capDir)` over the whole on-disk bundle) — NOT the repo-plantable ledger integrity nor the executable-only disclosure signature — otherwise the cap is DISCOVERED-BUT-INACTIVE (a warning carrying `kind:'unconsented'`, no surfaces, empty commandRoots), so a forged/cloned in-repo project ledger or any post-consent tamper no longer activates anything; GLOBAL scope (under the user's own home) is trusted without a record, and the global-vs-project root dedup/escalation is realpath-keyed so a symlinked `GSD_HOME` aliasing the project root cannot bypass the gate (finding 1). The consent lookup is wrapped to fail CLOSED (inactive); both the per-scope ledger AND the `capability.json` manifest are read via the shared bounded `readSmallRegularFile` (a repo-planted FIFO/oversized ledger or manifest can no longer hang or OOM the loader — finding 2). The loader reuses the ledger's shared `isValidLedgerEntry` for committed-entry parity. Consumers wired to the overlay-aware registry: `config-loader.cjs`, `config-schema.cjs`, `capability-state.cjs`, `loop-resolver.cjs`.
### Community Capability Registry
Human-facing discoverability catalog (`docs/registries/capability-registry.md`, generated from `docs/registries/capabilities.json`; issue #2182) listing third-party Feature Capabilities registered by a docs PR so a solo developer can find one before installing it. Distinct from **Capability Registry** (the generated runtime manifest compiled from first-party `capability.json` declarations, ADR-894) and **Capability Registry Overlay** (the runtime seam that merges an installed third-party manifest into that generated registry at load time, ADR-1244 D2): this registry is a static document rendered by `scripts/gen-registry.cjs`, not a runtime data structure or loader. Each entry enumerates the capability's Loop Extension Points and hook kinds so a reader can judge blast radius before running `gsd capability install`, and declares its `engines.gsd` range. Inclusion is an explicit non-endorsement — a maintainer merged a link, nothing more — per `docs/registries/README.md`.
### EoS Registry
Human-facing discoverability catalog (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the **Community Capability Registry**, but enumerate the six interface points, the eight negotiated axes plus an optional ninth (`effortSurface`), and `protocolVersion` in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See `docs/registries/README.md` for the full entry schema. One of three third-party discoverability catalogs alongside the **Community Capability Registry** and the **Reviewer Lane Registry**.
### Reviewer Lane Registry
Human-facing discoverability catalog (`docs/registries/reviewer-registry.md`, generated from `docs/registries/reviewers.json`; issue #2904) listing third-party `role: "reviewer"` capabilities (ADR-2782) — reviewer lanes that add an external review lane to `/gsd-review`, installed with `gsd capability install <spec>`. A lane registers on zero Loop Extension Points and owns no artifacts, which is why it cannot be listed on the **Community Capability Registry**: the Capability entry schema's `loopExtensionPoints`/`hookKinds` are unsatisfiable by construction for a lane. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the other two registries, but enumerate `slug`, `flags`, `transport`, `evidenceClass`, and `reviewsSection` in place of Loop Extension Points and hook kinds. See `docs/registries/README.md` for the full entry schema and disambiguation against the **Community Capability Registry** and **EoS Registry**.
### Capability Validator
Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`.
### Capability Source Resolver
ADR-1244 D3 fetch-and-stage seam (`gsd-core/bin/lib/capability-source.cjs`). Primary interface: `resolveCapabilitySource(spec, opts) → { id, version, stagedDir, integrity, source }`. Parses specs via `parseSpec` and dispatches to one adapter per source kind: `local` (fs copy from a `./`-prefixed path), `git` (clone `--depth 1` + checkout via `execGit`; https/ssh/git transports only — `ext::` and `file://` are rejected), `npm` (pack via `execNpm --ignore-scripts` + tar extract — NEVER `npm install`, no lifecycle scripts; shell-metacharacter spec rejection for Windows shell safety), `tarball` (HTTPS download + sha512 integrity verify BEFORE extraction + tar extract), and `registry` (explicit stub — no first-party endpoint yet). Security contract: install never executes capability code (copy/extract only); integrity is verified before staging; tar-slip member paths and symlinks are rejected. Staging is atomic: a per-pid/timestamp scratch directory under `.staging/` is renamed into `$GSD_HOME/.gsd/capabilities/<id>/` on success and removed on failure. The Phase 1/2 validator suite runs on the fetched manifest before finalizing; `engines.gsd` is pre-checked. #3514 (epic #1900 F21): `realHttpsGet` gates every fetch PRE-transport — loopback/link-local/metadata/unspecified hosts (127/8, 169.254/16 incl. cloud metadata, 0/8, ::1, fe80::/10, ::, v4-mapped spellings) and localhost-form names are denied with a named error, non-`https` URLs fail with a named https-only reason while `parseSpec` still CLASSIFIES `http://` tarball specs (no parse-time hard reject — internal-mirror flows; the transport never fetches plaintext), and RFC1918 ranges are deliberately allowed (internal mirrors; the denylist is not an allowlist; host-LITERAL check only — DNS rebinding out of scope). Test seams: `_setCapabilitySourceHttpGet` (whole fetch) and `_setHttpsGetImpl` (low-level transport, exercises the gate).
### Capability Ledger
ADR-1244 D4 per-runtime install manifest (`gsd-core/bin/lib/capability-ledger.cjs`). Leaf module (only `node:fs`/`node:path` plus `shell-command-projection`'s `platformWriteSync`). Records `{ id, version, source, integrity, files[], sharedEdits[{file,marker}] }` per installed capability in `.gsd-capabilities.json` at the runtime config dir root. Exports: `readLedger` (structural-validated, never throws), `writeLedger` (atomic via `platformWriteSync`), `recordInstall` (idempotent, prototype-pollution-guarded), `removeEntry`, and `reconcile` (reports orphans whose `files[]` are missing on disk; hardened against non-string/`..` members; never mutates). Serves as the atomic commit point for Phase-4 upgrade/remove and the reconciliation basis for detecting stale entries after out-of-band deletions.
### Capability Consent Store
Issue #1459 user-owned consent seam (`gsd-core/bin/lib/capability-consent.cjs`). Leaf module (`node:fs`/`node:path`/`node:os`/`node:crypto` + the ledger's shared bounded `readSmallRegularFile`/`readSmallRegularFileBuffer` + the shared `capability-lock` primitive). Stores `{ version:"1", records: { "<JSON disk key {r:realpath(projectRoot),i:id}>": { projectRoot, id, scope:'project', integrity, disclosureSignature, contentHash, consentedAt } } }` at `${GSD_HOME||homedir()}/.gsd/consent.json` — a USER-OWNED file OUTSIDE any repository. Exports: `consentStorePath(gsdHome?)`, `readConsentStore(gsdHome?)` (bounded via `readSmallRegularFile` + 8 MiB cap, NON-THROWING — missing/corrupt/oversized/FIFO/wrong-shape → empty `{records:{}}`; caps records at `MAX_RECORDS=4096`), `bundleContentHash(capDir)` (THE security binding — a `sha512-<base64>` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of every regular file AND directory under the bundle, with two NARROW exclusions from the DIGEST ONLY (#3631, amended after two orthogonal reviews found the original wholesale directory-skip unsafe): a `__pycache__`/`.pytest_cache` DIRECTORY's own marker is suppressed but the directory is ALWAYS recursed into (so no unbounded unhashed region — H1), and a `.pyc`/`.pyo` FILE is excluded only when its immediate parent basename is exactly `__pycache__` (a `.pyc` elsewhere, e.g. bundle root or `scripts/`, stays hashed — H2); `.DS_Store` is deliberately NOT excluded (stays hashed like any other file — an excluded filename is a permanently unhashed name a declared hook could still point at, and it is unrelated to #3631's Python-bytecode symptom); excluded entries still count toward the walk's size/count caps, and the filter runs AFTER the symlink/non-regular rejection so it can never be used to smuggle one past; manifest-declared hook `script` paths naming a `__pycache__`/`.pytest_cache` segment or a `.pyc`/`.pyo` basename are rejected by `isSafeHookScriptPath` (`capability-lifecycle.cts`/`capability-validator.cjs`), but that guard is NOT containment — it only inspects the declared `script` path string, so a hashed `hooks/run.js` doing `require('../__pycache__/mod.pyc')` reaches the excluded region in one hop via Node's default `.js` handler, after which that file is free to change post-consent with the digest unmoved; ACCEPTED RESIDUAL RISK: CPython's default timestamp-based `.pyc` invalidation checks only the source's mtime+size (both attacker-forgeable by whoever can already write the bundle), so a forged `__pycache__/*.pyc` matching an unmodified source executes without moving the digest — bounded only by requiring post-consent write access and by everything outside `__pycache__/*.pyc` staying hashed (the declared-surface guard is NOT a bound, per the indirection above); `.pytest_cache` CONTENTS still change the digest (only its directory marker is suppressed); `node_modules`/`dist`/`build` are deliberately NOT excluded because their contents are executed at runtime): length-FRAMED entry COUNT + per-entry TYPE tag + uint32 path-byte-len + RAW path bytes from a `{encoding:'buffer'}` dir walk [finding 4] + for files uint64 content-byte-len + RAW content bytes via `readSmallRegularFileBuffer` [finding 1b], plus typed DIR markers binding empty directories [finding 2]; symlinks/non-regular rejected; size+count bounded), `hasProjectConsent({gsdHome,projectRoot,id,contentHash})` (true iff a record for `${realpath(projectRoot)}\x00<id>` exists AND its stored `contentHash` equals the supplied recomputed hash — the binding is `contentHash`, NOT `integrity` and NOT `disclosureSignature` (those remain on the record purely for the human disclosure + re-consent-on-executable-change UX); unsafe ids → false; prototype-pollution-safe NUL-joined keys + `Object.prototype.hasOwnProperty`), `recordProjectConsent({gsdHome,projectRoot,id,integrity,disclosureSignature,contentHash})` (LOCKED, atomic+durable write — tmp `wx`/fsync/rename/dir-fsync mirroring `writeLedger`; enforces the record cap at write time) and `revokeProjectConsent({gsdHome,projectRoot,id})` (LOCKED atomic delete, no-op if absent) — BOTH **THROW** rather than perform an UNLOCKED read-modify-write when the consent-store lock cannot be acquired (finding 3; the lifecycle treats a consent-write failure as non-fatal, and the `trust revoke` CLI catches the throw and emits a clean error). This is the authoritative consent signal the loader recomputes (`bundleContentHash(capDir)`) and checks at load before activating a PROJECT-scope third-party overlay (declarative surfaces AND command dispatch): a forged/cloned in-repo project ledger, OR any post-consent tamper (swapped declarative manifest, edited hook script, empty-integrity local install — all change the recomputed hash), leaves the cap DISCOVERED-BUT-INACTIVE until the user consents on THIS machine to the EXACT bundle (the lifecycle records the consent on a consented project install/upgrade and revokes it on remove; install/lookup/revoke share one canonical `consentProjectRoot` root key). GLOBAL-scope overlays (under the user's own home) need no record; and when `GSD_HOME` resolves (via realpath, defeating symlink aliasing — finding 1) to a genuine project root the in-repo bundle still requires a record. The consent lock is the SHARED hardened primitive (below), so it never stale-steals a slow-but-live writer (finding 4). See `docs/explanation/capability-trust-model.md` "project-scope trust boundary".
### Capability Lock
Issue #1459 finding 4 shared cross-process lock primitive (`gsd-core/bin/lib/capability-lock.cjs`). Leaf module (`node:fs`/`node:path`/`node:os`/`node:crypto` + the ledger's bounded `readSmallRegularFile` + `shell-command-projection`'s `execTool` for the rare start-time shell-out). THE single hardened lockfile protocol shared by BOTH `capability-lifecycle` (the `.gsd/capabilities/.lock` mutation lock) and `capability-consent` (the consent-store `.consent.lock`) — extracted so the two locks cannot diverge (mirrors the shared-validator / shared bounded-reader lessons). Exports: `acquireLock(lockPath, opts?)` (O_EXCL create with a JSON `{token,pid,hostname,startTime,ts}` body; steal protocol binds age to the body's own `ts`, never stale-steals a VERIFIED-LIVE same-host holder — pid alive AND recorded start-time matches the pid's current start-time, defeating pid-reuse without ever stealing a live holder — and reclaims only a dead/unverifiable holder via the dead-pid fast path or the hard `LOCK_DEADMAN_MS` deadman; `opts.maxAttempts` raises the bounded retry budget and `opts.waitForFresh` makes a contended fresh/live holder be WAITED FOR rather than failed-fast so genuinely-racing consent writers serialize), `releaseLock(handle)` (token + inode owner-safe — never deletes a successor's lock), `getProcessStartTime`, and the `_setLockProbes`/`_resetLockProbes` test seams. Carries the #1462 lifecycle-lock invariants (process-start-time liveness, TOCTOU-safe pre-rename identity recheck, bounded iterative loop).
### Capability Trust Gate
ADR-1244 Phase 4 (D5) PURE policy module (`gsd-core/bin/lib/capability-trust.cjs`). Computes *what* a capability would do and *whether* policy permits it; performs no mutation and no I/O beyond existence-checking declared artifacts. Exports: `discloseExecutableSurfaces(manifest, stagedDir?, resolveHost?)` (enumerates the four executable surfaces — `hooks`, command modules, `mcpServers`, and reviewer lanes (ADR-2782 D5) — plus a fifth, non-executable class, instruction surfaces (`skills` stems only, ADR-2363 D5, #3248), returned as `instructionSurfaces`; flags `hasExecutable` from the four executable classes only — instruction surfaces deliberately do NOT contribute to it; a reviewer lane is the one class that *receives* data, so it discloses its binary + full args (spawn) or destination host + `hostConfigKey` (openai-http) together with the egress payload classes); `evaluateInstallTrust(args)` (composes source policy + reserved-namespace + engines gate + disclosure into `{ allowed, requiresConsent, disclosure, engines, blockReasons }`); `evaluateSourceAllowed(parsed, strictKnownRegistries)` enforcing `capabilities.strict_known_registries` (unset/null → permissive-with-consent; `[]` → block all external; non-empty → host-based allowlist, never substring); `checkEngines(manifest, hostVersion)` (engines.gsd hard gate via `semverSatisfies` + `compatVersions` graceful-downgrade picking the newest working version); `executableSetChanged(old, new)` (auto-update re-consent trigger); `checkReservedNamespace` (`gsd-`/`gsd-core-`/`anthropic-`); `collectInstructionSurfaces(manifest)` (the instruction-surface collector — `skills` stems only, independently testable, same total/`safeCollect` contract as the four executable collectors; ADR-2363 D3 classifies `agents` as an instruction surface too, but a third-party capability's declared `agents[]` are never staged into the agent's instruction context — `stageAgentsForRuntimeWithConverter` (`src/install-profiles.cts`) has no registry-aware third-party path the way `readInstalledCapabilitySkill` gives skills — so disclosing them would name a surface that does not exist; agents stay unimplemented pending a maintainer decision, and are NOT thereby safe or inert, only undisclosed); `summarizeInstructionSurfaces(disclosure)` (renders the instruction-surface section of the consent summary; called from BOTH branches of `summarizeDisclosure` because a skill-only capability has `hasExecutable === false` and takes the early return, so a section appended only at the end would never render for exactly the capabilities that need it). The MCP disclosure also captures each server's `env` (string→string, filtered) and `cwd` (#1459) — `disclosureSignature` folds them in as STABLE SORTED JSON so any env/cwd add/change forces re-consent while a key reorder does not; `signatureForManifest(manifest, stagedDir?)` is the single source of truth for that signature (consumed by the loader's consent check and the lifecycle's consent binding). #3514 (epic #1900 F21c): `evaluateInstallTrust` accepts optional `integrityPin` ('sha512' = a verified `--integrity` pin | 'git-commit' = a git `#sha:<7-40-hex-commit>` ref, hex-validated so a mutable `#sha:main` is NOT a pin | 'none') and sets a PROMPT-ONLY `Disclosure.integrityStatus` ('pinned' | 'commit-pinned' | 'unverified') rendered by `summarizeDisclosure` as a `NO PINNED HASH — staged unverified` line when no pin was supplied — deliberately EXCLUDED from `disclosureSignature` (consent's content binding is `bundleContentHash`, #1459; a rendered line must never fire a spurious re-consent), mirroring the ADR-2363 D4 instruction-surface exclusion one field over. #1459 finding 5: each MCP surface also carries `rawConfig` — the FULL declared server config the writer persists (`{...config}`), prototype-pollution-cleaned — folded into the signature as STABLE SORTED JSON so a change to ANY persisted field (not just the explicit whitelist — a future `envFile`/`workingDir`/launch option) forces re-consent, while a pure key reorder does not; the human summary stays readable via the key fields only. #3515 (epic #1900 F20): the MCP disclosure section additionally renders an INTENTIONALLY-NOT-CONFINED notice for every SPAWNED (stdio) server — command/args/cwd are written verbatim and may point anywhere on the machine, unlike the confined hook path (capability-lifecycle's MCP write documents the same asymmetry in code); remote-only (http/sse) servers render no notice (nothing local is spawned), and the line introduces NO new Disclosure field so disclosureSignature is untouched. Instruction surfaces are deliberately EXCLUDED from `disclosureSignature` (ADR-2363 D4, #3248) — a manifest gaining, losing, or changing `skills` produces a byte-identical signature and disturbs no stored consent record; any future binding arrives as a versioned v2, never an in-place re-encoding of v1. The barrier is consent + integrity + reversibility, NOT a sandbox — see `docs/explanation/capability-trust-model.md`.
### Capability Lifecycle
ADR-1244 Phase 4 (D5+D6) orchestration seam (`gsd-core/bin/lib/capability-lifecycle.cjs`) composing the source resolver, ledger, and trust gate into the mutating operations. Exports: `installCapability` (pre-fetch source gate → resolve copy-only with `promote:false` → trust verdict → promote + apply marker-stamped shared edits → **ledger commit**; nothing written on block/abort), `upgradeCapability` (atomic stage-then-swap: old set aside, new swapped in, shared edits re-derived, **ledger committed**, backup dropped; re-prompts when the executable set changed), `removeCapability` (strip only `_gsdCapability`-marked shared-config entries — user hand-edits preserved — delete exactly the ledger-recorded files, then drop the entry; `CAPABILITY_DATA` preserved unless `removeData`), `reconcileCapabilities` (crash recovery driven by the ledger's `_pending {kind,backupName,sharedFiles}` INTENT — not a version comparison: roll an uncommitted upgrade back by restoring the backup, an uncommitted fresh install away entirely, and re-sync shared config from the winning bundle, guaranteeing no half-state), plus `applyCapabilitySharedEdits`/`stripCapabilitySharedEdits` (marker-isolated JSON edits, prototype-pollution-guarded). All four mutating ops + reconcile take a cross-process lock (`.gsd/capabilities/.lock`, atomic stale-steal) so a concurrent reconcile can't clear a live intent. Capability code never executes during any operation. The source resolver's `promote:false`/`skipEnginesGate` options are the seams that let this module own the swap/commit ordering and the engines gate (with `compatVersions` downgrade hint).
### Capability Command Dispatch
ADR-1244 Phase 5 (D7) registry-driven dispatch of capability command families. First-party families (`graphify`/`intel`/`audit`, shipped in `bin/lib/`) dispatch via `dispatchCapabilityCommand` (`gsd-core/bin/gsd-tools.cjs`) against the FROZEN `capability-registry.cjs` `commandFamilies` (confined to `bin/lib/`) — unchanged. Third-party (installed overlay) families dispatch via `dispatchOverlayCapabilityCommand`: after the first-party path returns false, it calls `loadRegistry({ includeInstalled, cwd })` and dispatches a family iff its `capId` is in `_overlay.commandRoots` — which `capability-loader.cjs` populates ONLY for accepted overlay capabilities that declare `commands` AND pass the loader's activation gate (a **committed** ledger entry, present and non-`_pending`, PLUS — for PROJECT scope — a matching user consent record in the Capability Consent Store; GLOBAL scope needs no consent record). A bundle dropped on disk with no install (no ledger entry) or no on-this-machine consent is NOT command-dispatchable. The router module is `require()`'d FROM the capability's install root via `defaultRequireFromInstallRoot` (bare-`.cjs` basename + `realpath` containment, rejecting `..` traversal and symlink escape); same own-property/function/sync-only guards as the first-party path. Wired into the `runCommand` default arm before "Unknown command". A repo-planted project ledger no longer activates anything on its own (#1459) — see `docs/explanation/capability-trust-model.md` "project-scope trust boundary".
### Claude Orchestration Capability
Default-off, BETA, claude-only Capability (`capabilities/claude-orchestration/`, `role: feature`, `runtimeCompat.supported: ["claude"]`, `tier: full`, `activationKey: claude_orchestration.enabled`) adopting Claude Code's Workflow tool (the engine behind `/effort ultracode`, Agent SDK ≥ v0.3.149) as an optional parallel-execution backend for the GSD loop, and folding the `gsd-ultraplan-phase` plan-offload under the same runtime gate (#1143; ADR-1143). Pure, fail-closed core in `gsd-core/bin/lib/claude-orchestration.cjs`: `detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion }) → { available, backend:'workflow'|'inline', reason }` (gate ladder: enabled → Claude → execution_backend ≠ inline → host dispatch nested+background → valid Agent SDK → SDK ≥ floor; every miss degrades to `inline`, never throws); `emitWorkflowScript({ phaseDir, waves, runId, budgetTokens?, executorModel? }) → { ok, script, summary }` mapping waves → `parallel()` stage barriers, plans → `agent({ agentType:'gsd-executor', isolation:'worktree', model? })`, `files_modified` overlap → separate sequential stages (greedy first-fit), `resumeFromRunId` wired to the run id, shared `budget(tokens)`; all interpolated identifiers validated script-safe (no `"`,`\`,control chars) and briefs JSON-quoted (review anti-injection). #2686: `executorModel` — resolved for `gsd-executor` from the same config the inline path reads, defaulted by the router so no caller change is needed, `--executor-model` to pin — is emitted per plan and OMITTED when it resolves to `inherit`/empty/whitespace/non-string (#2517: an empty model 404s on runtimes without native tier aliases); a value carrying an unscriptable character is rejected outright (`ok:false`) rather than quoted, because the ADR-1411 provenance header interpolates it into a `//` comment where U+2028/U+2029 would terminate the comment and execute the remainder. `resolveWaveDispatch` forwards it. Registers two loop contributions at WIRED points only (execute:wave:pre/execute:pre are declared but not rendered, same constraint external-job documents): `execute:wave:post into:executor` (Workflow-backend guidance) and `plan:post into:planner` (ultraplan ownership declaration), both `when: claude_orchestration.enabled`, `onError: skip`. Federated config keys (`claude_orchestration.enabled` default false, `execution_backend` enum auto|workflow|inline default auto, `min_agent_sdk_version` string default "0.3.149") live only in the registry — uninstall removes them cleanly. Pre-release versions of the floor compare below GA (SemVer precedence). Restores the wave parallelism + plan-checker + verifier that #853 forces inline on Claude Code; on any runtime lacking the Workflow tool, behaviour is byte-identical to today. BETA v1 ships detection + emission + declarative ultraplan ownership + a `claude-orchestration` command family (`gsd-tools claude-orchestration detect-backend|emit-workflow`, router `gsd-core/bin/lib/claude-orchestration-command-router.cjs` from `src/claude-orchestration-command-router.cts`); full install-profile migration of the ultraplan skill into `skills[]` is a follow-up (CLUSTERS/profile gate). Test anchors: `tests/claude-orchestration.test.cjs`, `tests/claude-orchestration-command-router.test.cjs`.
### Loop Extension Point
A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks <point>` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing.
### Capability State Resolver
ADR-857 phase 4b/6 unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view consumed by workflow hook rendering. Source of truth: `gsd-core/bin/lib/capability-state.cjs` (generated from `src/capability-state.cts`). Interface: `resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd? }) → { capabilities: CapabilityStateEntry[] }` (pure, no I/O); `resolveCapabilityRuntimeState(cwd, runtimeConfigDir)` (I/O resolver shared by workflow dispatch and diagnostics — returns `{ runtimeConfigDir, warnings, capabilities }` only; `registry` and `config` are NOT returned — callers that need them import `capability-registry.cjs` and call `loadConfig(cwd)` directly); `cmdCapabilityState(cwd, runtimeConfigDir, raw, opts)` (I/O output entry point); `isCapabilityActive(capId, cwd): boolean` (convenience predicate — calls `resolveCapabilityRuntimeState`, finds the entry, returns `entry.active`; `false` when capability not found). CLI surface: `gsd-tools capability state [--config-dir <path>]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, enabled, active, hooks[] }` where `installed` = every owned skill ∈ installedSkills (or `installedSkills==='*'`; vacuously true for empty-skills caps), `surfaced` = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), `enabled = installed && surfaced` (unchanged — install+surface toggle only), `active = enabled && configActivation` (tri-state deepening: configActivation resolves the capability's `activationKey` via `_resolveActivationValue`; absent `activationKey` → configActivation=true, so `active===enabled` for ungated capabilities), and `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, configured, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (`configured` resolves `when`; `active = enabled && configured`). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys.
### Capability State Writer
The write-side mirror of the Capability State Resolver. Takes a desired capability state — per-capability `enabled` plus per-hook `gates` — and projects it onto the substrates: `enabled` drives the runtime surface (`.gsd-surface.json`) as the capability on/off switch; `gates` drive the federated config keys (`config.json` `workflow.*`) for hook-level granularity; the install profile (`.gsd-profile`) is a read-only floor it never writes. State-only and gate-only calls retain one atomic write per substrate. For a materializing request, it passes the in-memory surface candidate to `applySurface`; that writer publishes state only after artifact staging and synchronization succeed. It then re-runs the resolver and reports divergence (assert-and-report), so 'off means off' holds as a write-time invariant rather than by caller discipline. Source of truth: `src/capability-writer.cts`; the surface and config writers are its internal adapters.
### Capability Command Family [Planned — mechanism built, unconsumed]
ADR-959 (phase 4d) — a CLI command family (a top-level `gsd-tools` command and its subcommands) owned by a Capability via a new optional `commands: [{ family, module, router }]` field on the `feature` role. The Capability declares the `family` name, a first-party in-tree `module` (under `gsd-core/bin/lib/`), and the exported `router` — a standard `route*Command({ args, cwd, raw, error })` function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via `routeCjsCommandFamily`, owning its own subcommand list and arg parsing). The registry materializes a `commandFamilies` index (`family → { capId, module, router }`); the formerly-dead `_dispatchNonFamily` shim is replaced by a real `dispatchCapabilityCommand` (exported from `gsd-core/bin/gsd-tools.cjs`) consulted in `runCommand`'s **`default` case** — an unmigrated command hits its hardcoded `case`; a migrated command's `case` is removed so it reaches `default` → registry → router, making collision structurally impossible. The registry *discovers* a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. **Mechanism built (4d-impl-1):** `commands` schema + validator + single-family-ownership cross-check in `gen-capability-registry.cjs`; `commandFamilies` index emitted in the generated `capability-registry.cjs` (currently `{}` — no capability declares commands yet); `dispatchCapabilityCommand` wired into `runCommand`'s `default` case (behavior-preserving today). **Pilot complete (4d-impl-2):** `graphify` cut over as the first real capability command family — `capabilities/graphify/capability.json` bundles the command (`family: graphify`, `module: graphify-command-router.cjs`, `router: routeGraphifyCommand`), skill (`graphify`), config gate (`graphify.enabled`), and `tier: full`; the `case 'graphify':` arm removed from `gsd-tools.cjs`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.graphify → graphify-command-router.cjs → routeGraphifyCommand`; behavior proven equivalent (all subcommands: build, query, status, diff, build snapshot, unknown subcommand error, usage error, disabled gate). Template for phase-6 per-feature cutovers. **Audit cutover (4d-impl-3):** `audit-uat` and `audit-open` cut over as the second capability command family pair — `capabilities/audit/capability.json` declares two commands (`family: audit-uat`, `module: audit-command-router.cjs`, `router: routeAuditUat`) and (`family: audit-open`, `module: audit-command-router.cjs`, `router: routeAuditOpen`); the `case 'audit-uat':` and `case 'audit-open':` arms removed from `gsd-tools.cjs`; `commandFamilies` now holds `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies["audit-uat"|"audit-open"] → audit-command-router.cjs → routeAuditUat|routeAuditOpen`; behavior equivalence proven by existing regression tests (bug-2659, bug-2911, uat.test.cjs) plus new cutover tests. Confirms hyphenated family names pass registry validator (no format restriction beyond non-empty + non-reserved). **Intel cutover (4d-impl-4, last first-party cutover):** `intel` cut over — `capabilities/intel/capability.json` declares the command (`family: intel`, `module: intel-command-router.cjs`, `router: routeIntelCommand`) and the existing config gate (`intel.enabled`, default false); the `case 'intel':` arm removed from `gsd-tools.cjs`; `commandFamilies` now holds `intel`, `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.intel → intel-command-router.cjs → routeIntelCommand`; all 9 subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and both usage-error paths preserved; non-raw `timeAgo` transform on `status.files[*].updated_at` preserved exactly. `intel.enabled` is Capability-owned config after ADR-857 phase 6. Completes the initial 4d capability command cutover batch.
### Runtime Capability [Planned]
A `role: runtime` variant of a Capability (a Capability carries `role: feature | runtime`) that projects GSD's produced artifacts (skills/agents/hooks/commands) onto one host CLI's conventions — config-surface format, artifact-layout kinds, command template, hooks manifest, shared-hooks directory name, sandbox tier. It is a declarative descriptor over a fixed first-party primitive vocabulary (not a code adapter); install composes active Feature Capabilities × the chosen Runtime Capability at the InstallPlan seam (ADR-0058). First-party runtimes are authored through the same descriptor a third party would write (dogfooding the interface); tier-1 (Claude Code, Codex, Antigravity) is fully tested, the other existing runtimes ship lower-tier, none dropped. Third-party runtime loading is deferred to a purely additive external loader + trust gate. Note: "third-party" here is the authorship/distribution axis (who wrote/ships it), distinct from the integration-shape axis (in-host vs Connected Capability).
### Connected Capability [Planned — deferred design]
A Capability whose integration shape brings its own external process, service, or persistent state — for example an MCP server plus a backing database — rather than running entirely within the host's process and trust boundary as declarative artifacts and in-tree first-party code referenced by closed name. Orthogonal to authorship: a Connected Capability may be first-party (e.g. MemPalace, issue #956) or third-party. Contrast with a plain Capability (declarative artifacts + in-host-trust code) and a Runtime Capability (closed-vocabulary projection descriptor), both of which run within host trust. The Connected Capability contract — external-process/MCP-server/backend-provider contributions plus a trust and load gate — is deferred design (ADR-857 §7); vehicle issue #956. It is NOT expressed by the current capability schema.
`RULESET.CAPABILITY.off-means-off=the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.`
`RULESET.CAPABILITY.cutover-self-gating=a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — "invoke skill X at point Y when config Z" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.`
`RULESET.CAPABILITY.step-additive-gate-blocks=a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.`
`RULESET.CAPABILITY.precedence-engine-single-owner=the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly. #3661 adds a THIRD export from the same engine, _resolvePointGate(pointFrom,point,config,cwd,registry): a step's optional pointFrom field names a dotted enum config key; the step is active for its own `point` only when that key resolves to a value === point (found:false or a type/value mismatch → false). loop-resolver.cts's isActive and capability-state.cts's processHooks both call it (ANDed with the existing when gate) so a capability can register the same logical step at more than one loop point with config selecting which registration is live — see capabilities/code-review/capability.json's execute:post/execute:wave:post pair for the reference shape. capability-validator.cjs's validateAgainstContract requires pointFrom to reference an enum cap.config key whose values include the declaring step's own point (mirrors the pre-existing when-must-be-a-cap.config-key check for `when`).`
`SEAM.capability-activation-precedence-owner.owns=the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent), owned solely by src/capability-activation.cts`
`SEAM.capability-activation-precedence-owner.enforced-by=test:tests/capability-precedence-parity.test.cjs`
### Teams Status Module
Pure read-only detector for claude-code's experimental agent-teams feature (issue #1355). Stops gsd-core hanging silently when run under `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`. Source of truth: `src/teams-status.cts` → `gsd-core/bin/lib/teams-status.cjs`. Exports: `resolveTeamsStatus({ runtime, env }) → TeamsStatus` (pure, env injected, no process.env/disk inside — hermetic for tests) and `cmdTeamsStatus(cwd, { active? })` (I/O entry point; reuses `resolveRuntime` from `runtime-slash.cjs` for canonical `GSD_RUNTIME` → `config.runtime` → `'claude'` precedence). `TeamsStatus` shape: `{ active: boolean, runtime: string, env_present: boolean, source: 'on: env' | 'off: flag absent' | 'off: non-claude' }`. `active` is true only when the flag is strictly truthy (`"1"` or `"true"`, case-insensitive) AND the runtime is `"claude"`. CLI surface: `gsd-tools query teams-status [--active]` (default: JSON; `--active`: exit 0/1 boolean). Used only by a non-fatal `--active` check warning in `plan-phase.md` init block — does NOT block execution, does NOT activate capabilities, does NOT change behavior on any non-claude runtime.
### Wing
A MemPalace organizational unit corresponding to one project or repository. GSD derives the wing name from `project_code` or the project directory when `mempalace.wing` is unset. A wing contains Rooms. Cross-project Tunnels connect rooms across wings. MemPalace vocabulary — see Connected Capability, MemPalace memory capability (issue #956).
### Room
A named bucket inside a Wing that groups drawers by semantic kind. GSD maps its phase artifacts to five fixed rooms: `decisions` (CONTEXT.md), `planning` (PLAN.md), `milestones` (SUMMARY.md/UAT.md excerpts), `problems` (confirmed bug→fix pairs), and `learnings` (extract-learnings output). MemPalace vocabulary — see Wing.
### Drawer
A verbatim-content unit stored inside a Room. GSD files phase artifacts as drawers using `mempalace_add_drawer`; duplicate-check via `mempalace_check_duplicate` makes capture idempotent. GSD stores verbatim text (not AAAK summaries) to preserve recall fidelity. MemPalace vocabulary — see Room.
### Tunnel
A cross-Wing knowledge connection created by `mempalace_create_tunnel`. GSD proposes tunnels at `ship:post` when `mempalace.cross_project_tunnels: true`, linking rooms in the current wing to semantically related rooms in other project wings. MemPalace vocabulary — see Wing.
### Diary
A per-agent narrative entry written by `mempalace_diary_write`. GSD's `gsd-mempalace-curator` writes a diary entry at `ship:post` when `mempalace.diary_journal: true`, recording a session summary scoped to the project and agent role. MemPalace vocabulary — see Connected Capability.
### memory_mode
The `mempalace.memory_mode` config key controlling how authoritative MemPalace is during recall/capture relative to GSD's native memory. Three wired values: `augment` (default — palace is an additive recall layer; native memory stays authoritative; lowest coupling), `kg_backend` (knowledge-graph queries resolve against MemPalace's temporal graph as the primary source, `.planning/graphs/` as fallback; non-KG drawer recall stays additive), `replace` (recall resolves through the palace as the source of truth, native artifacts as fallback). Every mode is `onError:skip` and default-resilient — an unreachable palace degrades to native memory and GSD keeps writing `.planning/graphs/`, so no mode loses memory. Read at hook-render time; switching is a config change, not a reinstall. Cross-mode migration of existing `.planning/graphs/` into the palace is a separate, not-yet-implemented concern (PRD/ADR §17 open question). See MemPalace Settings in `docs/CONFIGURATION.md`.
### Runtime Hooks Surface Module
Standalone hook-surface writer module extracted from `bin/install.js` as ADR-857 phase 5f-1 (behavior-preserving relocation, no logic change). Owns: Cline rules-body/agents-md/pre-tool-use hook generation (`buildClineRulesBody`, `buildClineAgentsMdBody`, `buildClinePreToolUseHook`, `mergeGsdAgentsMd`, `writeClineArtifacts`); Cursor `hooks.json` lifecycle (`buildCursorHookEntry`, `isManagedCursorHookEntry`, `reconcileCursorHooksJson`, `writeCursorHooksJson`, `removeCursorHooksJson`); Copilot session-hook config (`buildCopilotHookConfig`, `writeCopilotHookConfig`); Codex hook-block and event management (`buildCodexHookBlock`, `rewriteLegacyCodexHookBlock`, `reconcileCodexHooksJsonEvent`, `reconcileCodexHooksJsonSessionStart`, `ensureCodexHooksJsonSessionStart`, `ensureCodexHooksJsonEvent`, `removeCodexHooksJsonEvent`, `removeCodexHooksJsonSessionStart`, `buildCodexHookWindowsShimIR`); Kimi native config.toml `[[hooks]]` lifecycle (`buildKimiHooksTomlBlock`, `stripKimiHooksTomlBlock`, `writeKimiHooksToml`, `removeKimiHooksToml` — #2095 EoS/kimi Upgrade 1, the first genuinely NEW hook surface added post-relocation rather than a behavior-preserving move: kimi's `[[hooks]]` array lives in its own native `config.toml`, resolved by `resolveKimiHooksTomlDir` in Runtime Homes Module to a directory deliberately separate from kimi's GSD configDir, wrapped in `# GSD Hooks BEGIN`/`END` marker comments for idempotent reinstall); and shared hook command helpers (`buildHookCommand`, `rewriteLegacyManagedNodeHookCommands`, `normalizeNodePath`, `resolveNodeRunner`, and — #3662 — `buildNodeRunnerChainToken`, the POSIX-sh runner token that resolves node at hook-fire time for non-portable managed JS hooks, plus the `NODE_RUNNER_RESOLVER_HOOK` basename of the staged `hooks/gsd-node-runner.sh` resolver that portable installs route through with the baked node path as its first argument). `bin/install.js` delegates to this module via thin wrappers and re-exports its functions unchanged so existing tests require no modification. Source: `src/runtime-hooks-surface.cts`. Built output: `gsd-core/bin/lib/runtime-hooks-surface.cjs`.
### Runtime Config Adapter Registry
Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `cursor-hooks-json` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | `antigravity` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Also exports `resolveInstallPlan(runtime)` — the ADR-58 `InstallPlan` capstone — which collects the install-level descriptor axes (`installSurface`, `writesSharedSettings`, `finishPermissionWriter`, `hookEvents`, `extendedHookEvents`, `hooksSurface`, `sandboxTier`) into one typed `InstallPlan` value consumed by `install()` and `finishInstall()` in `bin/install.js`. `sandboxTier` (`none` | `codex-agent-sandbox`) gates per-agent `sandbox_mode` emission in the codex TOML path and fails loud on a missing/invalid value (#1151). The spatial axes (`configHome`, `artifactLayout`, `commandStyle`) remain behind their self-resolving adapter modules and are not part of the plan; they are the execution adapters. Realizes both the adapter-selection and plan-collection halves of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60.
### Claude Code Plugin Manifest Module
Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner, write-guard) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). `hooks.json` covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: `config.json` → config-reload, injects `additionalContext` when `.planning/config.json` changes mid-session). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module.
### Knowledge Graph Module
Module owning the graphify integration: tri-state capability gate (`isCapabilityActive('graphify', cwd)` from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only `isGraphifyEnabled` gate, cutover in #1306), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Config leg reads `.planning/config.json:graphify.enabled`; all three legs (install, surface, config) must be active; writes to `.planning/graphs/`. Graph location override (#1825): `graphify.graph_path` in `.planning/config.json` (a path relative to the project root, or absolute) redirects where `graphifyQuery`/`graphifyStatus`/`graphifyDiff` read `graph.json` — so one umbrella-level cross-repo graph serves multiple sibling projects without N drifting mirror copies; the diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same dir); the auto-update status sidecar stays project-local; `writeSnapshot` honors the key (reads the configured graph, writes the snapshot alongside it); build stays project-scoped (`.planning/graphs/`) since the build skill hardcodes that destination — the umbrella graph is built in the umbrella project and sub-projects only READ it. Unset/blank/non-string → byte-identical `.planning/graphs/graph.json` default; a configured-but-missing file yields an actionable error naming the path. The key is registered in `config-schema.manifest.json` `validKeys`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`.
### Intel Module
Module owning the code-intelligence store: tri-state capability gate (`isCapabilityActive('intel', cwd)` from capability-state.cjs — honours installed+surfaced+config-enabled; replaces the former config-only `isIntelEnabled` gate, cutover in #1307; intel has `skills:[]` so installed/surfaced are vacuously true and the effective gate is `intel.enabled` in config), disabled response, query surface (`intelQuery` — full-text search across all intel JSON files), status surface (`intelStatus` — per-file freshness, 24-hour staleness threshold), diff surface (`intelDiff` — added/changed/removed files vs last-refresh snapshot), snapshot management (`saveRefreshSnapshot`/`intelSnapshot`), validation (`intelValidate` — existence, JSON validity, _meta.updated_at recency), api-surface render (`intelApiSurface` — generates `.planning/intel/API-SURFACE.md` from `api-map.json`), plus ungated utilities (`intelPatchMeta` — patches `_meta.updated_at` in any JSON file; `intelExtractExports` — extracts CJS/ESM exports from any JS file). Loop hook rendering gates on `state.active` (not `state.enabled`) so the `activationKey` config gate is honoured even without a per-hook `when` guard (Phase 4 tri-state alignment, #1307). Source: `gsd-core/bin/lib/intel.cjs`. Router: `gsd-core/bin/lib/intel-command-router.cjs`. See Capability Command Family Module (ADR-959 4d-impl-4) and Loop Extension Point.
### Research Module
The GSD-RESEARCH capability behind an L2-hybrid seam: code owns cache + provider policy + package legitimacy; MCP owns the actual fetch. Reachable via `gsd-tools query research-plan|research-store|package-legitimacy`. Source: `src/research-{store,provider}.cts` + `src/package-legitimacy.cts` (generated to `gsd-core/bin/lib/*.cjs` per ADR-457). Replaces the prose provider-waterfall duplicated across the researcher agents and the pip-install `slopcheck` bolt-on.
- `GSD-RESEARCH.MODULE.research-store=content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache`
- `GSD-RESEARCH.MODULE.research-provider=single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in the docs or web legs)`
- `GSD-RESEARCH.MODULE.package-legitimacy=registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate`
- `GSD-RESEARCH.INTEGRATION.L2-hybrid=code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches`
- `GSD-RESEARCH.PROVIDER.availability=config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env <X>_API_KEY or ~/.gsd/<x>_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal`
- `GSD-RESEARCH.CONTEXT-DISCIPLINE=less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob`
### UAT-Passed Predicate
Runtime-neutral predicate evaluating `*-UAT.md` / `*-VERIFICATION.md` result fields with markdown-aware parsing that ignores false-positive contexts (frontmatter body, fenced code, HTML comments, blockquotes). Returns `passed: true` only when all required checks pass; supports `--require-verification` to demand at least one VERIFICATION.md file alongside UAT results. Output envelope: `{ passed, uat_files[], verification_files[], checks[], blockers[], policy }`. Source: `gsd-core/bin/lib/uat-predicate.cjs`. Wired via `phase uat-passed` alias → `phase-command-router` → `cmdPhaseUatPassed`.
### Coverage Metadata Module
Deterministic classifier for the per-deliverable coverage RTM on SUMMARY.md (#1602). Parses the optional `coverage:` frontmatter block (a list-of-maps-with-nested-list-of-maps that `extractFrontmatter` cannot represent — so a dedicated indentation parser, sibling of `parseMustHavesBlock`), validates each entry's schema, and classifies each into `auto_passed` (deterministically covered) vs `present` (human UAT required). Output envelope: `{ mode, summary_file, total, all_auto_covered, auto_passed[], present[], errors[] }` with frozen `MODE`/`PRESENT_REASON`/`ERROR_CODE` enums. Auto-pass requires the narrow proven case (strict-boolean `human_judgment:false` AND non-empty all-`pass` verification AND zero errors); everything else, including a malformed entry, routes to `present` (fail-safe — never drops a deliverable, never false-auto-passes). `mode:legacy` (absent block) ⇒ caller falls back to prose `## Accomplishments` extraction, byte-identical for un-migrated phases. Source: `gsd-core/bin/lib/coverage.cjs`. Wired via `uat classify-coverage --summary <f>` → `cmdClassify`; authored by `execute-plan` create_summary, consumed by `verify-work` extract_tests. See `RULESET.WORKFLOW.COVERAGE-METADATA`.
### Eval Scoring Module
Deterministic eval-scoring projection (#10 / #1579) that moves the `gsd-eval-auditor`'s weighted arithmetic out of the prompt into code. `computeEvalScore(covered, total, infra[])` returns `{ coverage_score, infra_score, overall_score, verdict }` — coverage = `covered/total*100`, infra = mean of per-item weights (`ok`=1, `partial`=0.5, `missing`=0) over exactly 5 items, `overall = coverage*0.6 + infra*0.4` (2-dp rounding), verdict banded at 80/60/40 (`PRODUCTION READY` / `NEEDS WORK` / `SIGNIFICANT GAPS` / `NOT IMPLEMENTED`). `cmdEvalScore` is the CLI guard: rejects empty/NaN flags, `infra.length !== 5`, and out-of-domain counts (requires `0 <= covered <= total`). Pure arithmetic — no `.planning/` access (it is in `SKIP_ROOT_RESOLUTION`), no `Date.now`/`Math.random`. Wired via the `eval.score` verb (and the `eval score` spaced alias) → `eval-command-router` → `cmdEvalScore`; consumed by `gsd-eval-auditor`. Source of truth: `gsd-core/bin/lib/eval.cjs` (generated from `src/eval.cts`, gitignored per ADR-457). Tests: `tests/eval.test.cjs`, `tests/eval.property.test.cjs`.
### Probe Core Module
Generic spec-phase probe resolution model — the shared seam underlying spec-completeness probes (ADR-550 Decision 7). Owns the `status × verification` model (`status: resolved | dismissed | unresolved` × a per-probe `verification` tier), structural validation (`validateResolution`, `validateRequirement` — fail-closed: `verification` must be null unless status is `resolved`, and an out-of-enum status, a `dismissed`-without-`reason`, or an `unresolved` carrying a `resolution`/`reason`/tier payload all throw rather than silently miscount), the `analyzeCoverage(items, resolutions?, validators)` merge/rollup/orphan-reject pipeline, the `byVerification` per-tier rollup, and the `runProbeCli` I/O scaffold (parse → validate → analyze → emit, structurally guarding the report shape before write — a malformed report fails closed with stderr + exit 2 instead of stringifying as green). Adapter-agnostic: consumed by the Edge Probe Module today and the Prohibition Probe Module (#644) next. Exports (generic surface): `VALID_STATUS`, `validateResolution`, `validateRequirement`, `analyzeCoverage`, `runProbeCli` — the prohibition adapter exports that also ship from this module (`projectProhibitions`, `PROHIBITION_VALIDATORS`, `validateProhibitionResolution`, `dispositionForProhibition`) are documented under the Prohibition Probe Module's own locked-surface line. Source of truth: `gsd-core/bin/lib/probe-core.cjs` (generated from `src/probe-core.cts`, gitignored per ADR-457). Tests: `tests/probe-core.test.cjs`. See ADR-550 and Edge Probe Module. Under ADR-857 (phase-6 boundary, settled 2026-06-12) this seam is classified **core verification substrate** on the *contract* side: its deterministic validators are the verifier↔predicate contract's CI-testable surface (ADR-550 Decision 5) — core and non-toggleable, never an off-by-default Feature Capability. (The recall-gapped *generator* is the probe adapters that propose predicates, not this resolution engine — see Edge Probe Module and Verification substrate (predicate boundary).)
### Edge Probe Module
First adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase edge-completeness probe wired into spec-phase Step 5.5. Owns shape classification (`classifyShape`), the applicable-category relevance filter (`applicableCategories` over the 8-category edge `TAXONOMY`), edge proposal (`proposeEdges`), and the `{explicit, backstop}` verification validators; delegates merge/rollup/CLI to `probe-core`. Fail-closed input contract: an edge requirement with missing/empty `text` and no `shapes` override is rejected (a `{ id }`-only requirement no longer classifies to zero edges and silently drops), while the legitimate `shapes: []` opt-out is preserved; an optional `text_en` field, when present, must also be a non-empty string (#3717) or `validateRequirement` throws — an empty `text_en` would otherwise win `text_en ?? text` unnoticed (`??` does not catch `''`) and silently degrade classification. `SHAPE_CUES` are **English** word-boundary patterns, so classification needs an English-language input regardless of the project's `response_language`: `classifyShape` reads `text_en ?? text` (#3717), so spec-phase Step 5.5 populates `text_en` with a faithful English translation while `text` keeps the requirement's own wording and the SPEC keeps its original language; `id` is never translated (#2773 — without a translated field every requirement in a non-English project matched no cue and landed in `unclassified`, silently disabling the taxonomy; #3717 made the translation an explicit, validated field rather than an invisible convention on `text` itself). Downstream, the `plan-phase` planner lifts every `covered`/`backstop` edge from the SPEC `## Edge Coverage` section into `must_haves.truths`. Exports (locked surface): `classifyShape`, `applicableCategories`, `proposeEdges`, `analyzeCoverage`, `validateResolution`, `validateRequirement`, plus the constants `TAXONOMY` (the **closed** 8 edge categories), `UNCLASSIFIED_CATEGORY` (the `unclassified` review-manually sentinel for zero-cue prose, #1110 — deliberately **not** a 9th taxonomy entry: it stays out of `TAXONOMY` but is present in `EDGE_VALIDATORS.categories`), `VALID_SHAPES`, `SHAPE_CUES`, and `EDGE_VALIDATORS` (the `{explicit, backstop}` validators bundle injected into `probe-core`'s generic engine; its `categories` = the `TAXONOMY` ids plus `UNCLASSIFIED_CATEGORY`). The `Requirement` shape is `{ id, text, text_en?, shapes? }` (#3717 added the optional `text_en`; per-language cue sets were considered and declined — `en` remains the only `SHAPE_CUES` vocabulary). Source of truth: `gsd-core/bin/lib/edge-probe.cjs` (generated from `src/edge-probe.cts`, gitignored per ADR-457). Tests: `tests/edge-probe.test.cjs`, `tests/edge-probe-spec-phase-contract.test.cjs`, `tests/edge-probe-planner-contract.test.cjs`. See ADR-550 and Probe Core Module. Per ADR-857's phase-6 boundary (2026-06-12) the predicates this module generates are **core verification substrate** (they set the verifier's reach), so it is wired onto the core predicate rail as a core-default module rather than migrated to an off-by-default `capabilities/edge-probe/` Feature Capability.
### Verification substrate (predicate boundary)
The ADR-857 classification (settled 2026-06-12, prompted by @davesienkowski's boundary analysis on #857) that predicate-generation — the must-NOT-have / edge predicates that set the verifier's reach — is **core**, not an off-by-default Feature Capability. Load-bearing premise: *verifier reach = spec reach* (the verifier can only catch what the spec concretely names). Decomposes into: the **verifier↔predicate contract** (the verifier always expects predicates and grades **exogenously** against them — core, non-toggleable, a stability contract alongside the Loop Extension Point names), and the **generator** (the probe *adapters* that propose predicates — edge-probe's `classifyShape`/`proposeEdges`, the prohibition probe's adversarial LLM-propose — core-default but independently versionable, kept their own module because of a measured recall gap; `probe-core`'s deterministic validators sit on the contract side, not the generator side). Altitude rule distinguishing it from `gate` hooks: a gate runs *against* the spec (hook); predicate-generation defines the spec's *reach* (core). The decision-#6 `produces`/`consumes` artifact flow is the internal rail from generation to the core verifier. See ADR-857 *Verification substrate vs. plug-in tier (the predicate boundary)* and the ADR-550 cross-reference.
### Probe Family
The set of spec-phase completeness probes that share the Probe Core Module seam (ADR-550 Decision 7): the Edge Probe (Step 5.5, data-shape edges) and the Prohibition Probe (Step 5.6, unwritten *must-NOT* constraints) today, with room for a third nearly-free adapter. A "probe" walks each SPEC requirement, surfaces candidate omissions, and resolves each through the shared `status × verification` model — but each family member owns its own *recall* mechanism: a deterministic closed-taxonomy classifier for edges (shape→category), open-vocabulary adversarial LLM prose for prohibitions (recall is model-driven, not a compute adapter — ADR-550 D7b). What is shared is the resolution/validation/rollup engine and the soft-gate lifecycle; what diverges is how candidates are recalled. See Probe Core Module, Edge Probe Module, Prohibition Probe Module.
### Verification Tier
The orthogonal `verification` dimension a resolved probe item carries alongside its `status` (ADR-550 D7a — `status: resolved | dismissed | unresolved` is the shared resolution lifecycle; `verification` is the probe-defined enforcement axis). Each probe defines its own tier vocabulary: the edge probe uses `explicit | backstop`; the prohibition probe uses `test | judgment`. For prohibitions the tier names *how* a must-NOT can be enforced — `test` (a negative test can fail-close on it) vs `judgment` (an irreducible values/safety rule only human/LLM judgment can assess). Verify-phase routes on the tier: `test`-tier items must be provably wired and fail closed when unwired (never a silent green); `judgment`-tier items take the mode-dependent soft-gate (ADR-550 D4 — interactive demands human resolution, autonomous records a non-authoritative LLM-judge verdict + an `unverified-prohibition` flag, never a silent pass and never a hard halt). The rollup exposes `coverage.byVerification: { <tier>: count }` so verify-phase reads the per-tier denominator without re-scanning. See Probe Core Module, Prohibition Probe Module.
### Bespoke vs Canon Prohibition
The ownership seam between the prohibition probe and security/compliance tooling (ADR-550 D6). The probe owns **bespoke** product/values prohibitions — the unwritten must-NOTs specific to this feature's intent (e.g. "the streak reminder must not manipulate the user into returning"). When precision classifies an item as a **canon** security/compliance concern (OWASP / GDPR / fairness / prototype-pollution / path-traversal — the codified, cross-project rule sets), the probe does **not** mint a SPEC prohibition: it emits a one-line breadcrumb (*"possible canon-security concern X — owned by `/gsd:secure-phase` / eslint"*) and stops. Canon checks are **referred, not duplicated** — keeping the surfaced list short (#644's ~2–3-item precision goal) and the secure-phase boundary explicit. See Prohibition Probe Module.
### Prohibition Probe Module
Second adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase prohibition-completeness probe wired into spec-phase Step 5.6, surfacing the unwritten *must-NOT* constraints (values/safety/ethics) the spec never forbids. Unlike the Edge Probe, recall is **prose-orchestrated, not a compiled engine** (ADR-550 D7b) — a two-stage pass per requirement: Stage 1 an adversarial recall question, Stage 2 a one-pass precision classifier (drop routine engineering, keep genuine prohibitions). The code surface is schema/projection only: `projectProhibitions()` (deterministic SPEC↔`must_haves.prohibitions` projection backing the `RULESET.GENERATIVE-FIX` parity assertion), the `{test, judgment}` `PROHIBITION_VALIDATORS`, `validateProhibitionResolution`, and `dispositionForProhibition()` (the fail-closed default — an unwired `test`-tier item resolves to `unverified`/flagged, never green). **Deterministic test-tier locate (#1278, ADR-550 D3 addendum):** a resolved `test`-tier prohibition MAY carry an optional flat-scalar `check` descriptor — `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule only) — that `projectProhibitions` emits into `must_haves.prohibitions` when well-formed, and `descriptorFromProjection()` (the read-back seam in the #1259 enforcement producer, `src/prohibition-enforcement.cts`) reconstructs into a `{kind, target, rule?}` `CheckDescriptor`, so verify-phase locates the wired check with zero LLM/author authoring. **Flat scalars, never a nested `check:{}` object** — so the round-trip rides the *unchanged* shared `parseMustHavesBlock` (the #644 no-parser-rewrite precedent); an absent/partial descriptor falls through to the producer's existing fail-closed locate, and `failFirst` stays caller-attested (machine-proof is #1279). No `proposeProhibitions()` — recall is LLM prose. `plan-phase` lifts every resolved prohibition from the SPEC `## Prohibitions (must-NOT)` section into the `must_haves.prohibitions` sibling block (never `truths`). Exports (locked surface): `projectProhibitions`, `PROHIBITION_VALIDATORS` (the `{test, judgment}` validators bundle injected into `probe-core`'s generic engine), `validateProhibitionResolution`, and `dispositionForProhibition` (the fail-closed disposition) — the prohibition adapter surface, shipped from `probe-core` alongside the generic engine. Source of truth: `gsd-core/bin/lib/probe-core.cjs` (the prohibition exports live in `src/probe-core.cts`, gitignored per ADR-457) + `gsd-core/references/prohibition-probe.md`. Tests: `tests/prohibition-probe.*.test.cjs`. See ADR-550, Probe Core Module, Edge Probe Module, Verification Tier, Bespoke vs Canon Prohibition.
### Spec-Section Helper Module
The SINGLE source of truth for "did the phase SPEC supply section X (with at least one resolved row)?" — the SPEC-section detection seam consumed by `plan-phase` Step 7.95 (the spec-less probe fallback) to decide, per section, whether to run the fallback. Replaces the ad-hoc `awk` that previously lived in the workflow body, which hard-coded the section header strings at the call site and hand-rolled markdown-table row counting — a brittleness that produced two bugs: an exact `^## Prohibitions$` anchor that missed the canonical `## Prohibitions (must-NOT)` heading, and a single-table row-counting assumption. **Suffix-tolerant header invariant:** `SECTION_HEADERS` regexes match a heading AND any parenthetical/whitespace suffix — `prohibitions` matches both `## Prohibitions` and `## Prohibitions (must-NOT)`; `edges` matches `## Edge Coverage` (and any future suffix); if spec-phase renames a heading, update HERE and the `templates/spec.md` heading together (the contract is pinned by `tests/spec-section.test.cjs`). **Supply rule:** `supplied = present AND dataRows > 0` — a present-but-empty section is NOT supplied (it triggers the fallback). **Multi-table robustness:** a blank or prose line resets the per-table state, so a section with multiple tables (or prose between them) counts every table's data rows without miscounting a second table's header row; the `|…|` line before a `|---|` separator is the table header row and is never counted. **Fail-safe:** a missing/unreadable SPEC file resolves to `present:false` / `supplied:false` (so the fallback fires) rather than throwing. Exports (locked surface): the `SpecSectionKey` type (`edges | prohibitions`), `SECTION_HEADERS` (the canonical header matchers), the `SectionStatus` shape (`{ key, present, dataRows, supplied }`), `countSectionDataRows` (pure `specText → { present, dataRows }`), and `specSectionStatus` (disk-reading wrapper). CLI: `node spec-section.cjs <specFile> <edges|prohibitions>` prints `SectionStatus` JSON — exit 0 on success (an absent file is a valid "not supplied" answer), exit 2 only on a usage error (missing args / bad key). Pure and dependency-free. Source of truth: `gsd-core/bin/lib/spec-section.cjs` (generated from `src/spec-section.cts`, gitignored per ADR-457). Tests: `tests/spec-section.test.cjs`. See Edge Probe Module, Prohibition Probe Module, and `references/specless-probe-fallback.md`.
### MVP Mode
Phase-level planning **enrichment** layered on top of the default tracer-first decomposition (see Tracer Bullet): it frames the phase goal as a User Story and, on Phase 1 of a new project, emits a Walking Skeleton. Vertical slicing itself is now the default, so MVP Mode no longer *turns it on* — it adds the user-story framing + skeleton. Resolved at workflow init via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → `workflow.mvp_mode` config → false. All-or-nothing per phase (PRD #2826 Q1). Surfaced as `MVP_MODE=true|false` to the planner, executor, verifier, and discovery surfaces (progress, stats, graphify). Canonical parser: `roadmap.cjs` `**Mode:**` field; canonical resolution chain documented in `workflows/plan-phase.md`. Concept index: `references/mvp-concepts.md`.
### Claim Disposition
Three-way verdict every research claim carries before it may be shared, on the `/gsd-explore` Step-3 research pass (#2229): **admit** (survived a prompted-to-refute pass AND grounded in a source authoritative for that claim → stated with the source), **refute** (a source authoritative for that claim contradicts it → corrected, with the source), **abstain** (unverifiable, no primary support, a non-authoritative disagreement, a source-vs-prior conflict, or an untagged return → routed to the Unresolved Ledger, never smoothed into prose). Authority for the claim's own subject — not surprise — is the refute/abstain discriminator; a "strong prior" is never authoritative alone and can only produce an abstain. Two guards: **conflict-abstention** (a source-vs-prior conflict is never a silent pick-a-side) and the **tier floor** (every would-be admit is presented as an abstain when the researcher's resolved **tier** — `gsd_run query resolve-model <agent> --pick tier`, computed above the `resolve_model_ids: "omit"` gate in `resolveModelInternal` so it stays readable when the model id is blank or runtime-substituted — is `haiku` (over-defers to whatever source it was handed) or is `unknown`/`inherit`/empty (could not be determined, treated as potentially-cheap, never as verified-adequate); refute and abstain are unaffected. `--pick profile` defaults to `balanced` when `config.model_profile` is unset — it is not a tier signal. Disclosed residual, two cases: a per-agent `model_overrides` pin to a raw model id carries no tier, so it reports `unknown` and is floored — deliberate over-flooring in the safe direction; and a `model_profile_overrides.<runtime>.<tier>` entry that repoints a tier at another tier's model reports the asked-for tier, not the answering model's tier, so the floor stays silent on a cheap model — the one direction that fails open). A **prompt-level** judgment on this ideation surface: it deliberately does NOT call the verify-time `probe-core` disposition, which sits on the verifier↔predicate rail (ADR-857) and is out of altitude here. Claims-side analogue of the #1154 honest verifier (abstain-and-flag on the non-inferable; ADR-550 D4 — never a silent pass). Canonical text: `workflows/explore.md`. See Unresolved Ledger.
### Unresolved Ledger
The named output bucket a `/gsd-explore` research claim lands in when its Claim Disposition is **abstain** (#2229). Presented *side by side* with the admitted claims rather than folded into the narrative, each entry carrying its abstention reason (`unverifiable` | `source-vs-prior conflict` | `non-authoritative source` | `tier-floor: unearned confidence` | `untagged — disposition not reported`). Empty sections are suppressed; an all-unresolved outcome is stated in one line, because that is itself the signal. Exists to make the absence of grounding *visible* — the failure mode it replaces is a plausible-but-ungrounded claim entering the conversation as confident prose (the class the research pass targets: recent / version-drift facts the model is measurably overconfident on). See Claim Disposition.
### User Story
Phase-goal format under MVP Mode: `As a [role], I want to [capability], so that [outcome].` Required regex shape: `/^As a .+, I want to .+, so that .+\.$/`. Used as the framing input by `gsd-planner` (emits as bolded `## Phase Goal` header in PLAN.md) and as the verification target by `gsd-verifier` (the `[outcome]` clause is the goal-backward verification anchor). Authored interactively by `/gsd-mvp-phase`, validated by SPIDR Splitting when too large.
### Walking Skeleton
Phase 1 deliverable under `--mvp` on a new project — the Phase-1 whole-application special case of a Tracer Bullet: the thinnest end-to-end stack proving every layer (framework, DB, routing, deployment) works together. Emitted as `SKELETON.md` capturing the architectural decisions subsequent vertical slices inherit. Gate fires when `phase_number == "01"` AND `prior_summaries == 0` AND `MVP_MODE=true`. Scope intentionally narrow (PRD #2826 Q2) — does not retrofit existing projects.
### Vertical Slice
Single-feature task that moves one user capability from open-to-close (happy path) end-to-end. Contrast with the horizontal layer (all models, then all APIs, then all UI). The default planning unit under tracer-first decomposition (the leading task is a Tracer Bullet); SPIDR Splitting axes (Spike, Paths, Interfaces, Data, Rules) are the canonical decomposition tools when a slice is too large for one phase.
### Tracer Bullet
The default GSD decomposition lead: a **permanent, production-quality, minimal end-to-end slice** that wires one path through every layer a phase touches and becomes part of the skeleton of the final system — written for keeps, not thrown away. Contrast with a **prototype** (throwaway reconnaissance code, deleted once its lesson is learned): a tracer's *functionality* gaps are acceptable but its *architectural* gaps are not; stubs are allowed only where they can later be filled without an architectural change. GSD ships tracers, never prototypes — which is why `gsd-planner` LEADS every plan with a `type="tracer"` task (default; `--no-tracer` / `TRACER_MODE=false` opts back into horizontal layers) and `gsd-executor` runs an early integration feedback gate on the tracer's `<verify>` before expansion tasks (autonomous: halt-on-fail; interactive: honors `workflow.human_verify_mode` — under `end-of-phase` an automated-only `<verify>` continues with no checkpoint, otherwise `checkpoint:human-verify`, #3299). Origin: *The Pragmatic Programmer* "Tracer Bullets" (#1945); the Walking Skeleton is the Phase-1 whole-application special case. See Vertical Slice, MVP Mode, Walking Skeleton, Precondition.
### Precondition
The front-of-task side of the GSD plan contract (issue #1949, *The Pragmatic Programmer* Topic 23 — Design by Contract). An optional `<precondition>` element on `<task>` stating, in a single line of runnable/checkable prose, what must already be true for the task to begin safely — e.g. "`OPENAI_API_KEY` is set", "`dist/schema.json` from Phase 02 exists", "server responds to GET /health". `gsd-executor` evaluates it before any other task work, using **read-only checks only** (file existence, env var presence with no value output, idempotent `GET /health`-style pings — no writes, no network POSTs, no secret emission; if a side-effecting check seems required, the executor halts and surfaces a checkpoint rather than running it): met-or-absent is a no-op (back-compat for every existing plan); unmet returns a `checkpoint:human-verify` with no partial commit, and is NEVER auto-approved under `AUTO_CFG=true` (a missing prerequisite is a fact the executor cannot establish on its own, not a verification step). `gsd-planner` emits `<precondition>` in exactly three cases — `user_setup` consumption, prior-phase artifact dependency, or env-var/runtime-config dependency — when the assumption is not already guaranteed by `depends_on` ordering. Closes the contract triad whose other two sides are postconditions (`<verify>`/`<done>`/`<acceptance_criteria>`) and invariants (`must_haves.truths`). The structural validator (`cmdVerifyPlanStructure`) does not reject unknown optional tags, so adding `<precondition>` passes plan-structure validation unchanged. Canonical schema reference: `docs/reference/plan-md.md` → Preconditions; emission rules + anti-patterns: `gsd-core/references/planner-preconditions.md`. The architectural-end companion is the Tracer Bullet (#1945); together they close both ends of the "outrunning your headlights" failure mode. See Tracer Bullet.
### Reversibility Rating
Classification of a planning decision by what undoing it would cost later (issue #1951, *The Pragmatic Programmer* Topic 15 — "Reversibility"; Bezos's one-way/two-way door framing). Three closed values: `reversible` (undo is local and cheap — one file, one function, an implementation swapped behind a stable interface), `costly` (undo touches many call sites or needs a coordinated change — shared interface shape, cross-module contract, dependency major bump), `one-way` (undo requires a data migration, breaks a published contract, or is impossible — on-disk/wire format, public API shape, external-service lock-in). Surfaced two places: `discuss-phase` records it inline on `<decisions>` entries in phase CONTEXT.md as `— **Reversibility:** <rating> — <rationale>` (optional; an unrated decision is treated as `reversible`), and `gsd-planner` carries it onto the implementing task as the optional `<reversibility rating="…">` element. Planner behavior is rating-dependent: `one-way` inserts a `checkpoint:decision` BEFORE the dependent task (and forces `autonomous: false`), `costly` is flagged in the plan but never blocks, `reversible` flows normally. Reuses the existing `checkpoint:decision` mechanism — no new checkpoint machinery. Override `--no-reversibility-gates` (`REVERSIBILITY_GATES=false`, `/gsd:plan-phase`) suppresses checkpoint insertion for intentionally-unattended runs while still recording ratings, so the signal survives a run that chose not to stop for it. The structural validator (`cmdVerifyPlanStructure`) does not reject unknown optional tags, so `<reversibility>` passes plan-structure validation unchanged. Canonical taxonomy owner: `gsd-core/references/planner-reversibility.md`; schema reference: `docs/reference/plan-md.md` → Reversibility; the Reversibility Test thinking model (`references/thinking-models-planning.md` #4) is the reasoning step that produces the rating and consumes this taxonomy rather than defining a second one. Primary anti-pattern: rating everything `one-way` (checkpoint fatigue) — default to `reversible` when unsure, and prefer *removing* irreversibility (writer seam, versioned contract, vendor adapter) over gating it. The decision-risk companion to the Precondition (#1949), which guards implementation assumptions. See Precondition, Tracer Bullet.
### Behavior-Adding Task
Predicate over a PLAN.md task: `tdd="true"` frontmatter AND `<behavior>` block names a user-visible outcome AND `<files>` includes at least one non-`*.md` / non-`*.json` / non-`*.test.*` source file. Pure doc/config/test-only tasks are exempt. The MVP+TDD Gate (in `references/execute-mvp-tdd.md`) only halts execution on this predicate; the gsd-executor agent applies all three checks at runtime. Currently a prose-only specification — no shared utility.
### MVP+TDD Gate
Per-task runtime gate in `/gsd-execute-phase` that, when both `MVP_MODE` and `TDD_MODE` are true, refuses to advance a Behavior-Adding Task until a failing-test commit (`test({phase}-{plan})`) exists for it. The `tdd_review_checkpoint` end-of-phase review escalates from advisory to blocking under the same condition. Documented contract: `references/execute-mvp-tdd.md`. Reserved escape hatch `--force-mvp-gate` is documented but not implemented.
### SPIDR Splitting
Five-axis story decomposition discipline (**S**pike, **P**aths, **I**nterfaces, **D**ata, **R**ules) used by `/gsd-mvp-phase` when a User Story is too large for one phase. Full interactive flow per PRD #2826 Q3 (not a lightweight filter). Reference: `gsd-core/references/spidr-splitting.md`.
### Clock seam
An injectable time abstraction accepted as an optional parameter by production code (`{ clock = Date } = {}`). Test code substitutes `node:test` `mock.timers` to control time deterministically without waiting for real OS scheduler events. Canonical pattern established by ADR 456 (`docs/adr/456-test-rigor-architecture.md`).
### Process seam
The single subprocess-spawning primitive test code uses (`tests/helpers/process-seam.cjs`, #3055): `runNode` / `runGit` / `runHook`, each returning one discriminated union `{ outcome, exitCode, stdout, stderr, timedOut, signal, killed, code }` where `outcome` is the frozen `OUTCOME` enum (`EXITED` / `KILLED` / `TIMED_OUT` / `BUFFER_OVERFLOW` / `SPAWN_FAILED`). Every call is timeout-bounded — there is no unbounded code path — and nothing throws for a child's exit code, kill, timeout, buffer overflow, or spawn failure; all five are data. KILLED is a child terminated by a signal the seam did not send (a genuine OOM kill): `spawnSync` reports no `error` for that case, so it must be distinguished from EXITED, and the `runGsdTools` adapter retries it exactly as the pre-seam `isKilled()` did. This is what makes `timedOut` and `signal` assertable, so a fail-open guard's degraded verdict can be tested instead of merely observing that the call did not throw. Discrimination order is forced by runtime behavior: a timeout and a maxBuffer overflow are identical on both `status` (`null`) and `signal` (`SIGTERM`) and differ only by `code` (`ETIMEDOUT` vs `ENOBUFS`), so overflow is classified first — but that ordering assumes `status === null`, which is checked ahead of it: at the exact timeout boundary `spawnSync` can report `error.code === 'ETIMEDOUT'` on a result that ALSO carries a real `status` (the child finished on its own just as the timer fired), so `status !== null` is classified EXITED before any error-code branch runs, keeping `exitCode` coherent with the reported outcome. Per-suite wrappers remain and bind fixtures (cwd, env, payload); only the spawn body delegates here. Deliberately **not** a fault-injection surface — it cannot distinguish an injected timeout from a genuine bench OOM and would retry it; injection is in-process via `deps` (#3056). `runGsdTools` is an adapter over it that preserves its own legacy `{ success, output, error, exitCode }` shape and retry-once-on-kill behavior.
### Exit Code Registry Module
Generated central allocator for this repo's process exit codes (ADR-3889, epic #3889 — "nothing fails with success"; Phase 1, #3905). Declaration source of truth: `gsd-core/bin/shared/exit-codes.json` (append-only entries: `code`, `name`, `meaning`, `owner`, `authorizedBy`); generated artifact `gsd-core/bin/lib/exit-code-registry.cjs` (`EXIT_CODES`, `exitCodeFor(name)`, `nameForExitCode(code)`) is produced by `scripts/gen-exit-code-registry.cjs --write` and byte-guarded by `npm run lint:generated-sync` — the same declaration → generator → `--check` gate pattern as the Capability Registry, the Model Catalog, and the ADR index. Band contract (ADR-3889 §1): `0` and `1` are free and never allocatable here; `2` is reserved to the Claude Code hook-adapter protocol (owner must be `hook-adapter`); `3`-`13` are Node-reserved; `64`-`78` are the generic band; `80`-`125` are the domain band; every other value (`14`-`63`, `79`, `126`+) sits outside every band and is rejected — so every registered code is non-zero by construction. The generator also enforces one-number-one-meaning and one-name-one-code across the whole declaration. This module is the ALLOCATOR only: nothing in the generator or the generated artifact emits a registered exit code itself — wiring real call sites onto the registry is later work that does not land until #3906.
### Git fixture wrapper
The throw-preserving companion to the process seam (`tests/helpers/git-fixture.cjs`, #3143): `gitOrThrow(args, options)` runs `runGit` and returns `stdout` as a string on a clean exit, but throws on any other outcome. It exists because the seam **deliberately never throws** while `execSync` and `execFileSync` — the two forms 237 migrating call sites use — both throw on a non-zero exit. Migrating those mechanically onto `runGit` would convert a loud failure into a silent one: fixture setup that failed would return an empty string and surface as a baffling assertion failure further down. The thrown error carries `status` **and** `exitCode` as deliberate aliases (`status` is what the legacy `execSync` catch idiom reads, e.g. `tests/worktree-safety.test.cjs:1361`), plus `stdout`, `stderr`, `signal`, `timedOut` and `outcome`. Use `runGit` when every outcome is data you branch on; use `gitOrThrow` for fixture setup that must abort loudly. The seam module is **not** modified to add this — a throwing export would falsify the never-throws contract stated in its own header and in the `### Process seam` entry above. The module also exports `throwIfFailed(result, displayName)`, the single implementation of that throw shape: `gitOrThrow` itself is `throwIfFailed` specialized to `runGit`, so it routes through the same code path and the two cannot drift apart. Per-suite wrappers driving non-git targets — a node CLI via `runNode`, a bash snippet via `runHook` — call `throwIfFailed` directly rather than hand-rolling their own copy of this shape, which is exactly how five call sites had drifted from each other before this module exported it (#3144). It also exports `toLegacyResult(result)`, the non-throwing counterpart: a bare mapping onto the legacy `{ status, stdout, stderr }` shape (`status` aliasing the seam's `exitCode`) for call sites that already branch on exit status as data rather than wanting a throw — ~8 test files hand-rolled that identical three-line mapping before this module exported it too (#3147). Callers needing an extra field beyond that shape (e.g. a parsed-JSON body, a fixture-specific path) compose it — `{ ...toLegacyResult(result), extra }` — rather than folding the extra behavior into the shared helper.
### Unbounded-spawn guard
The lint rule enforcing `DEFECT.UNBOUNDED-SUBPROCESS` across the test suite (`eslint-rules/no-unbounded-spawn.cjs`, #3143, wired into the `tests/**/*.cjs` block of `eslint.config.mjs`). Flags `spawnSync` / `execFileSync` / `execSync` whose options carry no usable `timeout`. It resolves renamed destructures (`const { execSync: exec } = require('node:child_process')`) and chained requires (`require('node:child_process').execSync(...)`) rather than matching literal callee names — both forms exist in the suite today and a name-only matcher leaves them permanently invisible. It resolves an options object held in a single-write `const`, which is what keeps `process-seam.cjs` — the bounded reference implementation — from flagging itself. Two values are rejected as *nominally* bounded: `timeout: 0` (Node reads zero as no timeout) and anything above the 600000 ms ceiling (effectively unbounded); a non-literal value is trusted, since the target shape is one named constant with a comment. #3148 (the terminal wave of epic #3064) deleted `no-unbounded-spawn.allowlist.json` and its wiring from `eslint.config.mjs` once the migration reached zero remaining violations — the rule now runs with **no exemption surface** across `tests/**`; there is no allowlist to add a file to. The one companion guard that survives (`tests/no-unbounded-spawn-allowlist.test.cjs`) asserts that no `eslint-disable` naming this rule exists anywhere under `tests/` — with the allowlist gone, that inline disable is the *only* remaining way to silence the rule, so this guard is now the sole remaining defense. The only sanctioned escapes are an explicit `timeout` on a raw spawn (for a call shape the process seam cannot express, e.g. a `shell: true` invocation for `npm.cmd` on Windows, or `stdio` redirection to a real fd) and the ceiling-escape marker below. #3145 added an auditable ceiling escape: a literal timeout over the 600000 ms ceiling is permitted, without `timeoutTooLarge`, only when the call carries an inline `// allow-spawn-timeout-ceiling: <reason>` marker comment (the `// allow-test-rule: <reason>` idiom) with a non-empty reason, on the line immediately above the call or anywhere inside the call's own source range. It binds to that call only — a marker on one call never suppresses a different over-ceiling call elsewhere in the file — and it is deliberately narrow: it raises the ceiling for a call that already has a resolvable numeric timeout, it never waives the requirement for a bound, so a marked call with no `timeout` at all still reports `unboundedSpawn`. The real load-tested exception this exists for is `fragment-single-edit-propagation.install.test.cjs`'s `timeout: 900000` on `npm run regen:derived` (a full build plus eight generators), where 300000 was observed killing a genuinely-completed run near the end on a loaded bench.
### Deterministic scheduler
Test-execution model in which all timing and concurrency outcomes are fully controlled by the test (via clock seam, explicit `await` ordering, or synchronous stepping) rather than by the OS thread scheduler. Opposed to real-race tests, which are non-deterministic on loaded CI runners.
### Property-based test
A test that generates many adversarial inputs automatically (via `fast-check`) and asserts that a stated invariant holds for all of them, rather than asserting on a fixed set of hand-chosen examples. Invariant categories used in this codebase: round-trip, monotonicity, boundary containment, idempotency. See `RULESET.TESTS.property-based-testing`.
### Mutation testing / mutation score
Stryker injects small code mutations (e.g., flipping a `>` to `>=`, deleting a `return` statement) and reruns the test suite for each. A mutation is "killed" if at least one test fails; "surviving" if all tests pass despite the mutation. Mutation score = killed / total. Score below 80 % on the changed scope blocks PR merge. See `RULESET.TESTS.mutation-score`.
### ESLint harness
The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint-harness.md`): ESLint flat config (`eslint.config.mjs`) with `typescript-eslint`, `eslint-plugin-n`, `eslint-plugin-no-only-tests`, and a local AST-rule plugin at `eslint-rules/`. Replaces the homegrown `scripts/lint-*.cjs` regex scanners. All three custom test-rigor rules (`local/no-source-grep`, `local/no-magic-sleep-in-tests`, `local/no-elapsed-assertion`) now ship at `error` (promoted by #3313 and #3331 respectively, superseding the original #453 cleanup-sweep handoff).
### External-job-waiting half-state
A legal deferred state of an Execute step (`external_job_waiting`): the executor has dispatched a long-running async external job and committed an async-job manifest at `.planning/async-jobs/<job>.json` instead of a SUMMARY.md. Distinct from the synchronous "mid-production-commits" half-state and from an illegal partial-plan state. The core loop's step-completion + safe-resume/pause contract treats a non-terminal manifest as legal and reconciles against it (never re-dispatching the plan, which would duplicate the external job); SUMMARY.md is deferred until the job reaches a terminal state and its `expected_artifacts` are verified. The manifest is a versioned stability contract (`docs/reference/planning-artifacts.md`); core *consumes* it while a default-off scheduler-adapter Capability (#1164) *produces* it at `execute:wave:post` — the contract-is-core / producer-is-capability seam mirrors ADR-857's verification-substrate decision. Status enum is closed and scheduler-agnostic: `submitted`, `running`, `completed-unverified`, `failed`, `cancelled`, `timeout`.
### External-job Capability
The producer half of the async external-job contract (#1164, part of #1105). Default-off Capability (`capabilities/external-job/capability.json`) that *writes* `.planning/async-jobs/<job>.json` manifests — the only thing that does; core never writes them. SLURM is the first backend (`sbatch --parsable` submit, `squeue` poll with `sacct` fallback, terminal-state mapping); the design stays scheduler-pluggable via the `backend` field (LSF/PBS/Kubernetes batch forward-declared, not built). Contributions inject at `execute:wave:post` into the executor (classify runtime budget → externalize `long_compute`, commit manifest + handoff, return `external_job_waiting`, defer SUMMARY.md) and at `plan:post` into the planner (emit `<runtime_budget>` quick|medium|unknown|long_compute per task). Activation key `external_job.enabled` (default `false`); sibling keys `external_job.backend`, `external_job.artifact_dir` (default `Artifacts/jobs`, per-job dirs — no fixed log paths, no hardcoded cluster/partition/account), `external_job.submit_timeout_ms` / `external_job.poll_timeout_ms` (bounded subprocesses per CLAUDE.md). Pure producer logic — SLURM state→manifest-status mapping, manifest build/validate, `sbatch`/`squeue`/`sacct` parsers, and the fail-closed manifest writer (refuses a second non-terminal job for a `plan_id` already in flight; refuses to clobber a malformed manifest) — lives in `gsd-core/src/external-job.cts` (generated to `gsd-core/bin/lib/external-job.cjs`); the operator CLI surface is `scripts/slurm-adapter.cjs` (`submit`/`poll`/`show`). Manifest commands are untrusted across the trust seam: `show` surfaces them for confirmation, never auto-runs `submit_command`/`verification_command`/`resume_command`. Test seam: `tests/external-job.test.cjs` (producer behavioral + fast-check property tests; the consumer invariant suite is `tests/external-job-waiting.test.cjs`).
### Live-DOM UAT Capability
Default-off Capability (`capabilities/live-dom-uat/capability.json`, `role: feature`, `tier: full`, `runtimeCompat.supported: ["*"]`, `activationKey: workflow.live_dom_uat`) that confines browser MCP reach to ONE purpose-built agent (#2856). Owns the boolean key `workflow.live_dom_uat` (default `false`), the agent `gsd-dom-verifier` (`tools:` carries `mcp__chrome-devtools__*` + `mcp__claude-in-chrome__*`, and deliberately NO `Bash` and NO `mcp__playwright__*`), and one `step` hook at `execute:wave:post` (`ref.agent`, `fragment: fragments/execute-wave-post.md`, `produces: DOM-VERIFY.md`, `consumes: PLAN.md`, `when: workflow.live_dom_uat`, `onError: skip`) — additive per `RULESET.CAPABILITY.step-additive-gate-blocks`; `gates: []`, it can never halt a wave. **`agents/gsd-executor.md` is NOT widened, in any configuration** — the shape the report proposed and triage refused: for a first-party agent the static `tools:` list is the only control that exists (ADR-1244 D2 forbids a capability granting tools to one; ADR-857 D4's `contribution` injects prose, never permissions; there is no per-dispatch tool override), and ADR-1244 D5's *"there is no sandbox"* governs installed capabilities, not what a spawned subagent does with a granted tool. Containment is TWO independent fail-closed gates: `isCapabilityActive` renders a hook only on `state.active === true` (an installed-but-config-disabled capability renders nothing), plus the step's own `when`. Tool presence alone never activates it — a browser MCP configured for unrelated work is the exact case the default-off key exists for. The orchestrator half extends `gsd-core/workflows/verify-work/steps/automated-ui-verification.md` with a `<!-- gsd:live-dom-families -->` block naming both new families AND the key; **the pre-existing `mcp__playwright__*` branch keeps its prior gating (presence + `state:ui-phase-active`) and stays OUTSIDE that block** — pulling it behind a default-off key would silently remove working behavior from every current Playwright-MCP user on upgrade (Hyrum). The glob list is a SHARED CONSTANT across two surfaces (agent frontmatter + workflow block), so `tests/live-dom-uat.test.cjs` carries a parity assertion per `DEFECT.GENERATIVE-FIX-DIVERGENCE`. Landing it also closed a host gap: `execute:wave:post` dispatched only `contribution` + `gate`, so ANY registered `step` was declared and silently never run — `execute-phase.md` step 5.75 now dispatches every `kind == "step"` per `gsd-core/references/loop-hook-dispatch.md`, the same single-kind hand-roll that document names. **Browser-profile lock is tolerated, never coordinated**: `chrome-devtools-mcp` holds an exclusive lock on `$HOME/.cache/chrome-devtools-mcp/chrome-profile` and `--isolated` is a flag on the OPERATOR's own MCP-server registration that GSD neither launches nor parameterizes, so the verifier reports `could_not_look`/`profile_locked`, names the flag, and stops — no retry, no wait, no lease manager over a resource GSD does not own. `DOM-VERIFY.md` frontmatter is scalars only with a closed reason enum (`ok | no_criteria | no_browser_mcp | profile_locked | target_unreachable`) under a closed `outcome` (`verified | nothing_to_report | could_not_look`); **`nothing_to_report` and `could_not_look` are never conflated** — a report claiming "no issues" that never opened a browser is the ambiguous-run-notes defect the issue was filed about. Test seam: `tests/live-dom-uat.test.cjs`. Docs: `docs/how-to/enable-live-dom-verification.md`, `docs/explanation/live-dom-uat-capability.md`.
### Broken Windows Ledger
The enforced cross-phase defect register operationalizing GSD's no-defer discipline as a tracked artifact (#1950). Markdown file at `.planning/WINDOWS.md` (project-level, cross-phase) with YAML frontmatter carrying scalar counts (`schema_version`, `open_count`, `waived_count`, `fixed_count`, `total_count`, `last_updated`) for the FAST path the gate reads via jq without parsing JSON, plus a JSON code block as the AUTHORITATIVE entries source; the two cross-check and fail closed on drift. The rendered markdown table is a THIRD projection of that same source and is cross-checked the same way, but at the WRITE seam rather than the read seam (#3689): `writeLedgerAtomic` compares the on-disk table against `renderTable(<entries parsed from the on-disk JSON>)` and refuses with `WINDOWS_LEDGER_TABLE_DRIFT` before writing, so a hand-edited cell is never silently reverted and a table-only row is never silently erased. Deliberately NOT enforced in `parseLedger`: hardening the read would break `windows status` and the ship gate on exactly the ledgers an operator needs to inspect. Each entry: `{ id, kind, phase, file, line, description, status, reason, recorded_at, resolved_at }`; kinds are closed (`stub | todo | fixme | skipped-test | lint-warning | unmet-truth | unrun-verify | deviation`); statuses are closed (`open | waived | fixed`). The `broken-windows` Capability (`capabilities/broken-windows/capability.json`) registers one `ship:pre` gate with predicate `artifact-frontmatter-equals WINDOWS.md open_count == 0`; federated config key `workflow.windows_enforce` (default `false` — opt-in enforcement, tracking-only by default so a project can adopt the ledger before turning the gate on). Population is best-effort and never blocks execution: `agents/gsd-executor.md` appends stubs/skipped-tests/unrun-verifies via `gsd_run windows append` after writing SUMMARY.md. Source of truth: `src/broken-windows.cts` → `gsd-core/bin/lib/broken-windows.cjs` (pure `parseLedger`/`renderLedger`/`appendWindow`/`markWaived`/`markFixed` + I/O `cmdWindowsStatus`/`Append`/`Waive`/`MarkFixed`); CLI surface `gsd-tools windows status|append|waive|fixed`. Ship gate enforcement is a `capId == "broken-windows"` named specialization inside `gsd-core/workflows/ship.md` preflight's generic `kind == "gate"` dispatch loop (sibling to the `security` specialization; #3559 made that loop generic, so every OTHER capability's `ship:pre` gate is now evaluated through `gsd_run check predicate` instead of being resolved and silently dropped, while these two keep their bespoke fail-closed reads and are each visited exactly once); it reads `gsd_run windows status --raw` and fails closed on a non-zero/non-numeric `open_count` (an unparseable ledger is itself a broken window). `/gsd:progress` surfaces the open+waived count. The ledger is optional and backward-compatible: a project with no `.planning/WINDOWS.md` reports `open_count: 0` and ships cleanly, and with `workflow.windows_enforce=false` (the default) ship never blocks on it. Frozen `REASON` enum: `WINDOWS_LEDGER_MISSING | WINDOWS_LEDGER_MALFORMED | WINDOWS_LEDGER_TABLE_DRIFT | WINDOWS_ID_NOT_FOUND | WINDOWS_ALREADY_RESOLVED | WINDOWS_WAIVE_REASON_EMPTY | WINDOWS_INVALID_KIND | WINDOWS_INVALID_FILE | WINDOWS_INVALID_ID | WINDOWS_APPEND_MISSING_FIELD | WINDOWS_USAGE | WINDOWS_OK` — surfaced through `--json-errors` for typed test assertions. Test seam: `tests/broken-windows.test.cjs`. Origin: *The Pragmatic Programmer* Topic 3 (Hunt & Thomas — software transplant of Wilson & Kelling's broken-windows metaphor) plus Cunningham's debt metaphor (decay accrues interest ⇒ accounting, not just habit).
### Emitted Artifact Provenance
Cross-seam principle (ADR-2719, epic #2719): a committed artifact that is a pure function of the source tree is not reviewable state — it is derived state wearing a review costume, and it must be *attributable* rather than *pinned*. Concept, not a Module: it ships nothing, so it takes no `Module` suffix (follows the `### Resolution Provenance` precedent). Scope is the emitted-artifact family named by `RULESET.EMITTED_ATTRIBUTION`. The principle: every emitted path whose hash moved between `next` HEAD and PR HEAD must be attributable — through a declarative provenance table — to a path the pull request actually changed; unattributable deltas are a hard failure that *names them* rather than an anomaly a reviewer must notice inside 7,500 lines of hex. Totality is enforced, so an emitted path matching no rule fails loudly instead of passing through unattributed. The escape hatch is a commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3), not a committed document — two structurally distinct key spaces (separate maps, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa): `Emitted-Drift-Ack-Hash:` keyed on the emitted path (always contains `/`), `Emitted-Drift-Ack-Growth:` keyed on the bare filename under `gsd-core/workflows/` or `agents/` — key and reason separated by ` — ` (space, em dash, space; split on the FIRST occurrence), deliberately not a flag or env var — a trailer appears in the PR's commit list ONLY when something rippled unexpectedly, so adding one IS the alarm, whereas today 100% of emitted-byte changes touch fixtures and touching them signals nothing. Read from `git log $(git merge-base <base> HEAD)..HEAD` — the SAME merge-base `changedPaths` uses via `git diff base...HEAD`, so the ack set and the change set cannot disagree about which commits are this PR's; a two-dot range would be a defect. "Spent" no longer exists: a merged trailer is out of range by construction, not by computation, since there is no base-side copy to compare against. An uncomputable range (shallow clone) THROWS rather than passing vacuously — zero trailers means a PR needing one fails, a false red rather than a false green. `staleAcks` is retained: a trailer declaring a key nothing consumed is still a hard error, and the message names WHICH key space. This design is the terminus of a chain that began because the ack was PR-lifetime data kept in permanent shared state: the single legacy `tests/emitted-drift-ack.json` was a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 split it into per-PR fragments under `tests/emitted-drift-acks/`, ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left on `next` still walled off every path it owned until swept; #3842 and #3823 tried automated and hand-authored sweeps and each created the next conflict; #3875's timed sweeper automated the remedy but could not merge its own PRs. ADR-3942 ends the chain by moving the ack off the tree entirely (see `RULESET.EMITTED_ATTRIBUTION`). The same differential machine carries the size ratchet: growth is reported with exact byte deltas and needs the same acknowledgment, so anti-creep survives without pinning a number. Distinguish from the absolute check that remains: `tests/fixtures/install-tree/*.json` stays committed and normally-merged (ADR-2719 §7) because "the installer stopped shipping X" must fail with no attribution reasoning involved. Delivery was phased — #2721 naming + interim merge relief, #2722 provenance table + totality guard, #2723 differential check dual-run beside `golden-install-parity.test.cjs`, #2724 cutover (COMPLETE: the dual-run window observed agreement on real PRs after fixing #2750/#2760, and the golden fixtures/test/generator/merge-driver bridge are now deleted; the differential is the sole gate). The table LANDED in #2722 as `tests/helpers/emitted-provenance.cjs` (19 rules, guarded by `tests/emitted-provenance.test.cjs`); it maps emitted path → repo source and is TOTAL over EVERY emitted path in all 19 manifests — exactly one rule per path, with zero-match, two-match, AND dead-rule (a rule matching nothing) all hard failures, so table rot is loud in both directions. Deliberately NO path/family counts are recorded here: those move with every shipped-content edit, and a hand-maintained number in glossary canon is the exact silent-drift failure this whole seam exists to end. The guard recomputes them from the fixtures on every run — read them from a failure message, never from prose. What IS stable is the rule count, which changes only when a new emitted family or host appears. Note the surface is materially wider than #2722 estimated from `claude.json` alone (its "13 families / 15-20 rules" was a single-runtime sample; the 19-manifest surface spans runtime-specific roots — `.agents/`, `.kimi/hooks/`, `command/`, `agents/subagents/`, `.clinerules/`, `plugins/`, `extensions/`, `.gsd/`, the hermes `skills/gsd/` category and the #69 nested `skills/<router>/skills/<child>/` layout). Two design invariants carry forward to #2723: emitted SHAPES are hard-coded (deriving them from the installer would make the guard tautological — it would follow any installer change silently), while source PATHS may read a first-party descriptor where that descriptor is the sole declaration (`hostBehaviors.nativePlugin.source`); and attribution is keyed on `(rel, runtime)`, never `rel` alone, because one emitted path has different sources per host (`plugins/gsd-core.js` ← `.opencode/` vs `.kilo/`). Emitted skills attribute to `commands/gsd/*.md`, NEVER the repo `skills/` dir — that dir is itself generated from `commands/gsd` by `scripts/gen-plugin-skills.cjs`, so attributing to it is false attribution that still passes totality. Totality does NOT catch a rule pointing at the WRONG source (the recorded residual); the guard against that is the companion assertion that every attributed source EXISTS in the repo, which caught three real cases while the table was built (Copilot's `<name>.agent.md` rename, Kimi's code-literal `agents/gsd.{yaml,md}` root agent, and Copilot's `hooks/gsd-session.json`). A `sources` entry ending in `/` is a PREFIX, not a file — and prefix matching is SEGMENT-AWARE, so a source of `agents/` must not attribute `agentsfoo/x.md`. The differential check LANDED in #2723 as `tests/helpers/emitted-diff.cjs` (the conservation law, a PURE function — no fs/git/installer/clock) + `tests/helpers/emitted-baseline.cjs` (baseline resolution), guarded by `tests/emitted-attribution.test.cjs`. It ran DUAL beside `golden-install-parity.test.cjs` through the #2723 dual-run window with both green and fixtures untouched; #2724 deleted the golden fixtures/test/generator and the check is now the sole gate. Purity is deliberate and load-bearing: the naive one-big-integration-test shape would need ~38 installer spawns per assertion, so the four failing-first criteria would not in practice get written — which is exactly how a phase ships promised-but-not-built. Buckets are CONSERVED: every moved emitted path lands in exactly one of `attributed | unattributable | acked` (property-tested), and a path the provenance table cannot resolve surfaces as an ERROR rather than a silent skip. Four asymmetries worth knowing: an ADDED emitted key is a ripple too (not just modified ones); `synthesized` paths are exempt but `code-derived` ones are NOT (that is why Phase 2 refused to mark them exempt — exempt means permanently blind); SHRINKAGE needs no ack while growth does (gating shrinkage would punish what the ratchet wants); and a STALE ack — a declared key nothing consumed — is a hard failure (#2789 built spent/live detection against a base-relative document that persisted after merge, requiring `readAckFileAtRef`/`baseAck`/`spentAcks` and a `git show`-vs-`ls-tree` absence check to tell "never explained anything" from "already merged, and reddening `next` and every branching PR when it wasn't told apart" (#2768); ADR-3942 makes staleness structural instead of computed — every trailer read is definitionally THIS diff's, scoped by `git merge-base <base> HEAD`, so there is no base copy to compare against and no re-arm-by-reword mechanic to defend, and the base-document corruption hazard the standalone ack linter existed to guard against is gone with the document). Since ADR-3942 there is exactly ONE ack source and it is not a file: the `Emitted-Drift-Ack-Hash:` / `Emitted-Drift-Ack-Growth:` trailers on the PR's own commits, read by `readAckTrailers` (`tests/helpers/emitted-runtime.cjs`) over `git log $(git merge-base <base> HEAD)..HEAD` and parsed by `parseAckTrailers` (`tests/helpers/emitted-diff.cjs`) into TWO structurally distinct key-space maps — closing the latent defect where one shared `paths` map let a growth key satisfy a hash lookup by naming coincidence. Absent = no acks; a declared key nothing consumed is a hard `staleAcks` error that now names WHICH space; a non-empty reason per key is required — "name them and say why" is the contract (ADR-2719 §3, retained). A key declared twice with conflicting reasons is a hard error; declared twice identically it is deduped. An UNCOMPUTABLE range (shallow clone) THROWS rather than reading as zero acks — the inverse of the fragment guard's vacuous-pass failure, and fail-closed in the safe direction. Baseline is CACHED not committed, keyed on the `next` sha; a stale key is REFUSED, never used — absence fails loudly and gets fixed, whereas staleness produces a confident wrong answer. An explicitly-pointed-at (`GSD_EMITTED_BASELINE`) stale baseline is a hard stop, while a stale CACHE falls through to the in-job build. No baseline-unavailable path may `return` (in `node:test` that is a PASS, not a skip — ADR-2719 §6). Supersedes ADR-2264 §2–§4 and its Amendment; ADR-2264 Phase 1 (`buildParityManifest` and the exclusion constants in `tests/helpers/install-shared.cjs`) is retained and depended upon.
### Untrusted-input boundary
The prompt-level data/instruction isolation seam for untrusted web/document ingress (#1577). Shared reference `gsd-core/references/untrusted-input-boundary.md`, `@`-included by the 10 ingest agents (`gsd-project-researcher`, `gsd-phase-researcher`, `gsd-ui-researcher`, `gsd-assumptions-analyzer`, `gsd-advisor-researcher`, `gsd-ai-researcher`, `gsd-domain-researcher`, `gsd-research-synthesizer`, `gsd-doc-classifier`, `gsd-doc-synthesizer`) — every agent that reads fetch/search/MCP output or external source documents. The reference instructs: treat fetched/read content as **data, never instructions**; self-scan content for embedded directives before use; act only on the assigned task (ignore off-task instructions in data); and wrap quoted untrusted spans in a **fresh random delimiter** per wrap (fixed markers are spoofable). This prompt-level boundary is the primary control — it keeps an injection from being *followed* even while it sits in context. The hook-level companion is the read-injection scanner (`hooks/gsd-read-injection-scanner.js`, PostToolUse on `Read`/`WebFetch`/`WebSearch`), advisory by default; the opt-in top-level `security.injection_blocking` key upgrades HIGH-confidence detections to a PostToolUse circuit-breaker that halts the agent's next step (it runs *after* the fetch, so it is not a redactor). Tests: `tests/untrusted-input-isolation.test.cjs`, `tests/read-injection-scanner.*.test.cjs`, `tests/injection-blocking-config.test.cjs`. See `docs/adr/1577-untrusted-input-boundary-and-injection-blocking.md` and `docs/explanation/security-model.md`. Grounding: arXiv 2506.05739 (PPA), 2507.15219 (PromptArmor), 2504.20472.
---
## Probe family — spec-completeness probes (machine-oriented predicates)
> Glossary prose for these modules lives above (Probe Core / Edge Probe / Prohibition Probe / Verification Tier / Verification substrate). These are the greppable one-line predicates ADR-550's Consequences promised alongside the glossary. Research-derived numbers (N17/N18 rates) are deliberately kept out of this machine-canon and live hedged in `docs/design/verifier-reach.md`. (That design note and `docs/adr/1606-prohibition-enforcement-verify-seam.md` were co-delivered sibling PRs of epic #1605, now closed and landed; the predicate refs to them below are live.)
`PROBE.principle=verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md`
`PROBE.family=edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)`
`PROBE.protocol=recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason`
`PROBE.core.seam=analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)`
`PROBE.item.axes=status{resolved|dismissed|unresolved} x verification{<probe-defined>|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)`
`PROBE.edge.verification=explicit|backstop`
`PROBE.prohib.verification=test|judgment`
`PROBE.ui.verification=explicit|backstop`
`PROBE.ui.axis=MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)`
`PROBE.ui.seam=ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)`
`PROBE.ci.surface=the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)`
`PROHIB.recall=LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)`
`PROHIB.canon-referral=OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)`
`PROHIB.enforce.green-rule=passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default`
`PROHIB.enforce.kinds=node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)`
`PROHIB.enforce.failfirst=MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)`
`PROHIB.enforce.causation=clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)`
`PROHIB.descriptor.shape=5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)`
`PROHIB.rail=core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability`
`PROHIB.judgment-tier=never-silent / never-hard-halt soft gate; autonomous emits "unverified-prohibition — human review recommended" (exogenous grading, ADR-550 D4)`
`PROHIB.enforce.adr=docs/adr/1606-prohibition-enforcement-verify-seam.md (verify-time enforcement seam) + docs/adr/550-spec-phase-probe-contract.md (spec-phase contract)`
---
## Test rules and lint
`RULESET.TESTS.no-source-grep=local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)`
`RULESET.TESTS.no-source-grep.exemption=// allow-test-rule: <runtime-contract-is-the-product> with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.`
`RULESET.TESTS.no-source-grep.tmp-file-traps=reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()`
`RULESET.TESTS.no-duplicate-fold-marker=local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe("folded:<marker> ...") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at "." and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label ("B1 #1970" vs "B5 #1975") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file).`
`RULESET.TESTS.no-duplicate-fold-marker.why=consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green.`
`RULESET.TESTS.escape-regex=new RegExp("prefix${var}") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter`
`RULESET.TESTS.no-dead-regex-in-includes=src.includes("foo.*bar") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete`
`RULESET.TESTS.guard-toplevel-readFileSync=module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load`
`RULESET.TESTS.coderabbit-fix-prefer=behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep`
`RULESET.TESTS.diagnostics=after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes`
`RULESET.TESTS.boundary-coverage=tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; "very small" and "very large" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs`
`RULESET.TESTS.feedback-loop-convergence=when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — "after N phases", "subsequent", "over time", "improves", "learns", "adapts". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read "After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs`
`RULESET.TESTS.boundary-coverage.fixtures=for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)`
`RULESET.TESTS.boundary-coverage.anti-pattern=test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)`
`LEARNING.prompt-budget.boundary-gap=PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures`
`RULESET.TESTS.no-timing-assertion=do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, error (promoted by #3331 once #3314 delivered the ADR-456 §(a) reachability rule + deterministic backfill precondition); canonical replacement: clock-seam pattern with node:test mock.timers`
`RULESET.TESTS.clock-seam=concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS=<epoch-ms> in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)`
`RULESET.TESTS.property-based-testing=modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge`
`RULESET.TESTS.mutation-score=Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification`
`RULESET.TESTS.mutation-runner=Stryker executes every shard through the OFFICIAL @stryker-mutator/tap-runner (testRunner:'tap'), never the built-in 'command' runner (#3915); 'command' is the one runner Stryker excludes from coverage analysis, which forced coverageAnalysis:'off' and made cost strictly linear in (mutants x whole-shard test time) — the frontmatter shard measured 1751s on run 33021042847 vs 212s for the next slowest. tap.testFiles is injected per shard via MUTATION_TEST_FILES (mutation.yml env <- matrix.tests <- scripts/mutation-matrix.cjs buildResult); resolveMutationTestFiles is the SINGLE fail-closed reader and existence-checks every entry, because the tap runner's findTestyLookingFiles resolves the list with glob() and a non-matching pattern yields an EMPTY list SILENTLY (a fast, confident, meaningless run). tap.forceBail is FALSE by measurement, not preference: 3 of 26 shard test files spawn subprocesses (config-schema.property, core-utils, feat-3881-yaml-parser-consequences) and bail fires on every KILLED mutant, so leaving it on kills processes mid-spawnSync and orphans their children; Stryker's separate disableBail still skips remaining FILES, which is most of the win. tap.nodeArgs and top-level buildCommand stay UNSET so no rebuild lands between mutation and test (ADR-457). Coverage granularity is per FILE, not per test ("a test is always a test file"), so the #2790 excludeTests bans on spawn-heavy integration files remain necessary and unchanged`
`RULESET.TESTS.mutation-score-denominator=the gated number is mutation-testing-metrics' mutationScore = totalDetected/totalValid, which counts NoCoverage in the denominator EXACTLY as Survived; both Stryker's own thresholds.break (core dist/src/reporters/mutation-test-report-helper.js) and scripts/check-mutation-score-ratchet.cjs read THAT field, which is what makes the #3915 coverageAnalysis 'off'->'perTest' switch score-neutral. NEVER gate on mutationScoreBasedOnCoveredCode — it EXCLUDES NoCoverage and inflates sharply under perTest (measured on a synthetic report: 8 killed/2 survived = 80 and 80; 8 killed/2 noCoverage = 80 and 100), so swapping to the better-sounding field would make every minScore floor trivially satisfiable and the gate decorative. Under the pre-#3915 coverageAnalysis:'off' the two fields were ALWAYS identical (noCoverage was structurally 0), which is why nothing had ever pinned the choice; tests/mutation-score-ratchet.test.cjs now pins it with a non-vacuity assertion that the two numbers genuinely diverge`
`RULESET.TESTS.delete-bad-tests=pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern`
`RULESET.TESTS.eslint-harness=ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); all three test-rigor rules now ship at error in tests/**/*.test.cjs scope: local/no-source-grep and local/no-magic-sleep-in-tests promoted by #3313, local/no-elapsed-assertion promoted by #3331 once #3314 delivered its ADR-456 §(a) precondition (epic #1885 was subsumed into epic #3053 and closed stale before this promotion landed)`
`RULESET.AUDIT.search-source-not-generated=verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false "5e ConverterName unenforced"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep`
`RULESET.WORKFLOW_MARKDOWN.FENCES=preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)`
`RULESET.WORKFLOW_SIZE_BUDGET=workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an `Emitted-Drift-Ack-Growth:` commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3's fragment model — key is the bare filename, reason follows ` — `)) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: "not yet baselined" is exactly "present in sizeCurrent, absent from sizeBaseline", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification`
`RULESET.AGENT_SIZE_BUDGET=agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same `Emitted-Drift-Ack-Growth:` commit trailer (ADR-3942, superseding ADR-2719 §3's fragment model) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724`
`RULESET.EMITTED_ATTRIBUTION=the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS "recompute" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves "the installer stopped shipping X" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's "conspicuous declaration" only works if the contributor can discover how to make it. Both failing branches name the commit trailer to add — `Emitted-Drift-Ack-Hash:` or `Emitted-Drift-Ack-Growth:` (ADR-3942) — print its exact grammar (`<key> — <reason>`, key and reason split on the FIRST em dash), and repeat "do NOT regenerate anything" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT, now STRUCTURALLY DISTINCT trailer key spaces (separate maps since ADR-3942, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa) and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`, `Emitted-Drift-Ack-Hash:`), the size ratchet keys on the BARE FILENAME (`Emitted-Drift-Ack-Growth:`; `currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to drop the trailer line (amending the commit) when removing its last entry, since a lingering unused trailer signals nothing; post-#2789 it also offers CORRECTING the reason to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs, whose example line is rendered via `renderAckTrailer` (`<name>: <key> — <reason>`, ADR-3942) so the taught grammar cannot drift from what `parseAckTrailers` actually accepts (a round-trip test feeds the printed line back through the parser); a key that is reserved (`__proto__`/`constructor`/`prototype`) or contains `<`, `>`, or whitespace is rejected loudly, and a doc example like `<emitted/path> — <reason>` must never parse as a real declaration. Note the ADR's Consequences originally called the #2724 migration "terminal"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. The ack was PR-lifetime data kept in permanent, shared, merge-path state, and each fix generated the next defect until ADR-3942 moved it off the tree entirely (see `### Emitted Artifact Provenance`): the single shared `tests/emitted-drift-ack.json` was a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 replaced it with per-PR fragments under `tests/emitted-drift-acks/` — the `.changeset/` shape — ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left on `next` still walled off every key it owned (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier) and added the post-merge-only `guard-no-ack-on-next` job plus a manual sweep; #3842's hand sweep handed three in-flight external PRs a `modify/delete` conflict each; #3823's hand-authored sweep, computed at branch time against a guard that evaluates at merge time, lost the race to a fragment merged mid-flight and left `next` red for 24 consecutive pushes; #3875's timed sweeper workflow automated the remedy but could not merge its own PRs (three independent, deterministic defects — bad conventional-title match, wrong CI-lane classification, no auto-merge path). ADR-3942 ends the chain: the escape hatch is now a commit trailer scoped to the PR's own commits, so there is no shared file, no shared key namespace, and nothing to sweep — the fragment directory, the next-lane guard job, the scheduled sweep workflow and the standalone ack linter are all DELETED (named by ROLE rather than by filename on purpose: a backticked path here asserts a LIVE repo path and `check-glossary-refs.cjs` fails on one that does not exist, while `lint-removed-but-needed.cjs` additionally fails on a deleted file's bare BASENAME appearing anywhere it scans — and this predicate's generated projection lands in docs/, which it does scan. ADR-3942 carries the exact paths; it sits under docs/adr/, which that guard exempts as a historical record). cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance``
`RULESET.WORKFLOW_FILE_NAMES=workflow files use hyphens; <step name="..."> XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name`
`RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \`bug-3135-capture-backlog-workflow\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill`
`RULESET.WORKFLOW_EXECUTE_END_TO_END=standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)`
`RULESET.WORKFLOW.COVERAGE-METADATA=#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary <f>` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task <verify> results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch`
`RULESET.ALLOWED-TOOLS-FRONTMATTER=command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss`
`RULESET.ARGUMENTS-SANITIZE=any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\, max-length) — "(already sanitized)" must trace back to explicit guard; RESUME/fallback modes need own guards`
`RULESET.SHARED-HELPERS-LINT-VS-TEST=when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise`
`RULESET.ADR-HEADER=every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title`
`RULESET.MANIFEST-CANONICAL-KEY=docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by <workflow>/<subdir>/<file> path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib; #3762 added the ROSTER half — tests/inventory-manifest-sync.test.cjs now also asserts every manifest entry has a hand-written row in docs/INVENTORY.md, via the pure matcher in tests/helpers/inventory-roster.cjs. Scope is the SIX FLAT families only, each searched inside its own `## ` section; workflow_steps/workflow_modes are DELIBERATELY exempt because docs/INVENTORY.md §"Workflow Sub-Files" is a shipped decision that they carry no hand-written per-file rows. Matching is whole-CELL-exact (never substring — the rostered host-integration-adapters/imperative-hook-bus.cjs must not satisfy the separate top-level hook-bus.cjs) and section-scoped (smart-entry.md and smart-entry.cjs are different families), EXCEPT commands, which match on the row's Source-column link to ../commands/gsd/<file>.md because the six ns-* namespace routers deliberately RENDER a name that is not their file stem (/gsd-workflow ← ns-workflow.md) — DEFECT.DISPLAY-VALUE-AS-IDENTITY. Landing the gate required backfilling 32 pre-existing unrostered surfaces on next`
`RULESET.PR-SCOPE.one-concern-per-pr=split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit`
`RULESET.TRIAGE-EXISTING-WORK=before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep <issue>, (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement`
`RULESET.CR-THREAD-RESOLVE=after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:"PRRT_..."}) { thread { isResolved } } }'`
---
## CodeRabbit + repo-process guards (machine-oriented predicates)
`RULESET.CONTRIB.GATE.ORDER=issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog`
`RULESET.CONTRIB.CLASSIFY.fix=requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)`
`RULESET.CONTRIB.CLASSIFY.enhancement=requires approved-enhancement before implementation`
`RULESET.CONTRIB.CLASSIFY.feature=requires approved-feature before implementation`
## Workspace seams (machine-oriented predicates)
`RULESET.GH.AUTH.DEFAULT=source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback`
`RULESET.CODERABBIT.GUARD.OPEN_PRS=gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run`
`RULESET.CODERABBIT.GUARD.COMPLETE=required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0`
`RULESET.CODERABBIT.GUARD.GRAPHQL=reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone`
`RULESET.CODERABBIT.GUARD.RERUN=after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved`
`RULESET.CODERABBIT.GUARD.RESOLVE=fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query`
`RULESET.CODERABBIT.GUARD.SCOPE=if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete`
`RULESET.TESTS.CODERABBIT_FIX=prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule`
`CI.GATE.issue-link-required=hard-fail if PR body lacks closes/fixes/resolves #<issue>`
`CI.GATE.changeset-lint=hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label`
`CI.GATE.repair-sequence(PR)=create issue -> apply approval label -> edit PR body w/ closing keyword -> apply no-changelog if appropriate -> re-run checks`
`PR.3267.POSTMORTEM.root-cause=[missing issue link, missing changeset/no-changelog]`
`PR.3267.POSTMORTEM.recovery=[issue#3270 created, label approved-enhancement applied, PR reopened, body includes "Closes #3270", label no-changelog applied]`
`WORKTREE.SEAM.current=Worktree Safety Policy Module`
`WORKTREE.SEAM.files=[gsd-core/bin/lib/worktree-safety.cjs]`
`WORKTREE.SEAM.interface=[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]`
`WORKTREE.SEAM.default-prune-policy=metadata_prune_only (non-destructive)`
`WORKTREE.SEAM.decision-1=retain non-destructive default; destructive path only as explicit future opt-in scaffold`
`WORKSTREAM.INVARIANT.migrate-name=must normalize through canonical slug policy`
`WORKSTREAM.INVARIANT.slug-contract=all .planning/workstreams/<name> must be addressable by set/get/status/complete`
`WORKSTREAM.REGRESSION.test-anchor=tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug`
`ARCH.SKILL.improve-codebase.next-candidates=[Workstream Progress Projection Module]`
`WORKTREE.SEAM.test-policy=cover all decision branches in policy module before changing prune behavior`
`WORKTREE.SEAM.test-anchors=[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]`
`WORKTREE.SEAM.invariant=parser failure must degrade to metadata_prune_only and never escalate to destructive removal`
`WORKTREE.SEAM.inventory-interface=[listLinkedWorktreePaths, inspectWorktreeHealth]`
`WORKTREE.SEAM.caller-rule=verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers`
`WORKTREE.SEAM.test-anchor-w017=tests/orphan-worktree-detection.test.cjs + tests/worktree-safety.test.cjs`
`WORKTREE.SEAM.inventory-snapshot=snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers`
`SEAM.worktree-safety-policy.owns=Worktree Safety Policy Module — resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, W017 classification (see WORKTREE.SEAM.* above for full interface/invariant detail)`
`SEAM.worktree-safety-policy.enforced-by=test:tests/worktree-safety.test.cjs`
`PLANNING.PATH.PARITY.project-scope=.planning/<project> (never .planning/projects/<project>); mirror planning-workspace.cjs planningDir()`
`PLANNING.PATH.SEAM.helpers=helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root`
`PLANNING.PATH.SEAM.init-handlers=[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)`
`WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation`
`WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream`
`CONFIG.SEAM.loadConfig-context=loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites`
`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal`
`CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2`
`CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second`
`CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST`
`LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite`
`LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled`
`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates <root>/hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot`
`LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing`
`LIVE-CONFIG.GUARD.SEAM.severity=reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1`
`LIVE-CONFIG.GUARD.SEAM.ci-blind=the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes`
`PHASE.REQ-LINE.SEAM.module=src/phase.cts owns the ROADMAP **Requirements**: line seam as TWO module-scope functions, extracted so the parser is directly testable (a closure inside cmdPhaseComplete is reachable only by spawning the CLI, which no fast-check property can do): analyzeRequirementsLine(rawLine) -> RequirementsLineAnalysis, formatRequirementsLineWarning(phaseNum,rawLine,analysis) -> {code,message}|null; both exported, plus REQ_LINE_WARNING_CODE`
`PHASE.REQ-LINE.SEAM.selector-identity=citedReqIds is BYTE-IDENTICAL to the pre-extraction expression and is the ONLY thing that reaches the ledger; every rule below adds to warnings[] and NOTHING else — a change that alters what phase.complete MARKS is out of this seam's contract, not a refinement of it`
`PHASE.REQ-LINE.SEAM.rules=R1 whole-token range | R2 spaced operator between two selected interior-implying endpoints | R2' operator glued to one endpoint | R3 zero selection with ID-shaped residue | R3b zero selection on any non-placeholder non-empty line (#3697 AC-1b/AC-4) | R4 an ID the selector dropped to DECORATION — the TRIGGER is exactly a glued ;/: at either end OR an embedded invisible, never styling: quotes/backticks/emphasis are TOLERATED around the id (shaved before the test) but do NOT fire on their own, so `**REQ-01**, REQ-02` and `**REQ-01**; REQ-02` are BOTH silent — plus outside any MATCHED parenthetical AND sharing a prefix with a SELECTED id (square brackets stripped as the selector strips them; parentheses deliberately NOT, they are the citation marker) | over-cap unclassified; warn is their disjunction, named rather than inlined in the return literal`
`PHASE.REQ-LINE.SEAM.kinds=three, carried as a machine code BESIDE the prose, never instead of it — req-line-misparse (ID-shaped content demonstrably not selected) | req-line-range-reading (R2 alone fired on selected endpoints and NO RULE NAMED A DROPPED ID — rule-scoped, never line-global: an unselected parenthetical is carried by the skipped-text rider, not by this code — so the voice must NOT claim a parse failure; it defers to req-line-unverified when the cap left a token UNCLASSIFIED, which is narrower than 'a token past the cap' — a long token the SELECTOR ITSELF took can be exempt, so the condition is oversizedTokens being non-empty, never the mere presence of a long token — see SEAM.cap for the exemption's own rule) | req-line-unverified (a token past the cap: the line was not classified, which is not the same as clean); emitted as the additive result field requirements_line_warning, because warnings[] is a documented string[] rendered by execute-phase.md and re-typing its elements is a breaking output-contract change; ABSENT entirely on a clean line`
`PHASE.REQ-LINE.SEAM.cap=REQ_TOKEN_SCAN_LIMIT=2048 bounds every predicate including each range participant's NEIGHBOURS, not just the operator; a bound hit sets oversizedTokens and takes the unverified kind — an unexaminable token MUST NOT read as clean, and the cap bounds the WORK, never the warning; boundary covered at {2047,2048,2049} across BOTH capped predicate families per RULESET.TESTS.boundary-coverage`
`PHASE.REQ-LINE.SEAM.placeholder=TBD/NONE as the LEAD token only, and R3b's non-empty test keys on VISIBLE content (a line of only zero-width/bidi/variation-selector codepoints is empty; an invisible INSIDE a token is decoration and R4 reports the drop); that gate — not the ID-shape gate — is what holds the whole #2334/#2339 negative space silent under R3b (measured: 15 of 15 fixtures held by non-zero selection or placeholderLed, 0 by ID shape)`
`PHASE.REQ-LINE.SEAM.census-domains=TWO open domains, each censused in-source with its NOT-reached consequence: range-operator spellings (reached: ..+, seven Unicode dashes plus ASCII -, …, to/thru/through; not reached: →, ~, ..=, ..<, until, up to) and comma-SUBSTITUTE separators (round 4's 26-spelling sweep concluded 'exactly ; and : ' because it swept the ONE-SIDED form for ;/: and only the BARE and SYMMETRIC forms for every other separator — different members tested in different shapes, so the answer was forced; re-swept round 5 FULLY CROSSED at 21 separators x {bare,trailing-space,leading-space,both} = 84, driven: 26 select both, 24 already warn, 34 UNDER-SELECT SILENTLY and all 34 are one-sided attachment — | / + & \ > . ! ? • · ؛ ; , - ~ and/plus — so R4 covers TWO CHARACTERS of a WIDE-OPEN domain, never the whole of it); R4's own NOT-reached set is therefore styling-only decoration, every non-;/: attachment, anything inside a MATCHED parenthetical, and any decorated id whose prefix is on NO selected id (REQ-01, FOO-02: x stays silent even when FOO-02 is real); the prefix gate is NOT complete in the other direction either — a citation SHARING a selected prefix (REQ-01, see REQ-7: sec 3) still fires and nothing at token level separates it from a real drop`
`PHASE.REQ-LINE.SEAM.gap-checker-divergence=normalizePhaseReqIds (src/gap-checker.cts) is a SECOND parser of the same ROADMAP value and DIVERGES on four axes — ranges (expanded there, never here, deliberately per #3697) | placeholder vocabulary (whole trimmed value after stripping parens there, LEAD token here) | parentheses (stripped there, not here) | ID shape (PHASE_REQ_ID_SHAPE_RE is wider) — pinned in BOTH directions by #3697-17 rather than unified, because unifying would change what phase.complete MARKS; consequence is user-visible: RANGE-01..RANGE-05 reports 5 requirements to gap analysis and 0 to phase complete`
---
## Release notes standard
`RELEASE-NOTES.SCOPE=GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)`
`RELEASE-NOTES.DEFAULT-STATE=auto-generated body is "What's Changed" PR list + Full Changelog link; treat as draft, not final`
`RELEASE-NOTES.GATE.hotfix=manual edit required; auto-generated body for vX.Y.{Z>0} is "Full Changelog only" and must be replaced with structured body`
`RELEASE-NOTES.GATE.rc=manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard`
`RELEASE-NOTES.GATE.minor=auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix`
`RELEASE-NOTES.STANDARD.taxonomy=Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation`
`RELEASE-NOTES.STANDARD.heading-level=## for category, ### for subgroup (area), - for bullet`
`RELEASE-NOTES.STANDARD.bullet-shape=**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.`
`RELEASE-NOTES.STANDARD.subgroups=phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security`
`RELEASE-NOTES.STANDARD.footer.hotfix=Install/upgrade: \`npx @opengsd/gsd-core@latest\``
`RELEASE-NOTES.STANDARD.footer.rc=Install for testing: \`npx @opengsd/gsd-core@next\` (per branch->dist-tag policy)`
`RELEASE-NOTES.STANDARD.footer.full-changelog=**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/<prev>...<this>`
`RELEASE-NOTES.STANDARD.intro=optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes`
`RELEASE-NOTES.SOURCE.commits=git log <prev-tag>..<this-tag> --pretty=format:'%s%n%n%b' --no-merges`
`RELEASE-NOTES.SOURCE.changesets=.changeset/*.md (frontmatter pr: + body bullets)`
`RELEASE-NOTES.SOURCE.pr-bodies=gh pr view <NNN> --json title,body for fixes lacking a changeset`
`RELEASE-NOTES.SOURCE.precedence=changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)`
`RELEASE-NOTES.WORKFLOW.edit=gh release edit <tag> --notes-file <path>`
`RELEASE-NOTES.WORKFLOW.view=gh release view <tag> --json body --jq .body`
`RELEASE-NOTES.WORKFLOW.token=must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth`
`RELEASE-NOTES.WORKFLOW.idempotency=gh release edit overwrites body wholesale; safe to re-run after refining`
`RELEASE-NOTES.ANTI-PATTERN=raw "What's Changed" PR list as final body for hotfix or feature release; "Full Changelog only" body for tagged release with >0 user-facing fixes`
`RELEASE-NOTES.ANTI-PATTERN.implementation-first=do not lead bullet with file path or function name; lead with symptom/user-visible behavior`
`RELEASE-NOTES.ANTI-PATTERN.risk-commentary=do not include "may break", "be careful", "test thoroughly" - release notes state what changed, not hedges about what might go wrong`
`RELEASE-NOTES.EXAMPLE.hotfix=v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups`
`RELEASE-NOTES.EXAMPLE.rc=v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy`
`RELEASE-NOTES.EXAMPLE.minor-auto-acceptable=v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles`
`RELEASE-NOTES.TEMPLATE.hotfix=## Fixed\n\n### <subgroup>\n- **<bold change>** — <explanation>. (#<PR>)\n\n---\n\nInstall/upgrade: \`npx @opengsd/gsd-core@latest\`\n\n**Full Changelog**: <compare-url>`
`RELEASE-NOTES.TEMPLATE.rc=<one-paragraph intro>\n\n## Added\n### <subgroup>\n- **<change>** — <explanation>. (#<PR>)\n\n## Changed\n### Architecture\n- **<refactor>** — <user-visible benefit>. (#<PR>)\n\n## Fixed\n### <subgroup>\n- **<fix>** — <explanation>. (#<PR>)\n\n## Documentation\n- **<docs change>** — <reason>. (#<PR>)\n\n---\n\nThis is a release candidate. Install for testing:\n\`\`\`bash\nnpx @opengsd/gsd-core@next\n\`\`\`\n\n**Full Changelog**: <compare-url>`
`RELEASE-NOTES.RELEASE-STREAM.main-branch=next (RCs) + latest (stable); install via @next or @latest`
`RELEASE-NOTES.RELEASE-STREAM.rule=streams do not mix; do not document @next in hotfix/stable notes`
---
## Repo-rule reinforcement — k320..k331
`META.RULE.canonical-source-precedence=CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory`
`META.RULE.read-contributing-first=read CONTRIBUTING.md sections "Pull Request Guidelines" + "CHANGELOG Entries" before EVERY agent dispatch`
`META.RULE.brief-must-cite-doc=agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations`
`META.RULE.brief-no-paraphrase=writing "k040 — never leave changelog box unchecked" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110`
`PRED.k320.signal=changelog-direct-edit-forbidden`
`PRED.k320.canonical-source=CONTRIBUTING.md L193-211`
`PRED.k320.rule=do not edit CHANGELOG.md in feature/fix/enhancement PRs`
`PRED.k320.cure=drop .changeset/<adj>-<noun>-<noun>.md fragment ONLY`
`PRED.k320.tool=npm run changeset -- --type <T> --pr <NNN> --body "..."`
`PRED.k320.types=Added|Changed|Deprecated|Removed|Fixed|Security`
`PRED.k320.opt-out-label=no-changelog`
`PRED.k320.ci-enforcement=scripts/changeset/lint.cjs`
`PRED.k320.ci-paths-monitored=bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/`
`PRED.k320.recovery=open Removed-typed cleanup PR deleting only the redundant row`
`PRED.k320.evidence=PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09`
`PRED.k321.signal=cr-outside-diff-range-finding`
`PRED.k321.shape=CR posts "[!CAUTION] outside the diff" findings in review BODY, not in reviewThreads`
`PRED.k321.poll-shape=parse pulls/<n>/reviews body AND graphql reviewThreads`
`PRED.k321.resolution=address in code; no GraphQL resolveReviewThread needed for body-only findings`
`PRED.k321.evidence=PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads`
`PRED.k322.signal=cr-sustained-throttle`
`PRED.k322.distinct-from=k080`
`PRED.k322.shape=ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min`
`PRED.k322.cure-1=2nd retrigger ~10min after first ack`
`PRED.k322.cure-2=if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body`
`PRED.k322.merge-gate-impact=k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment`
`PRED.k322.evidence=PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers`
`PRED.k323.signal=sibling-audit-cross-pr-overlap`
`PRED.k323.shape=2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff`
`PRED.k323.cure-pre-dispatch=brief one agent canonical-owner; brief others to EXCLUDE shared site`
`PRED.k323.cure-alt=consolidate into single PR when 2+ issues share root cause`
`PRED.k323.recovery=close smaller PR as "subsumed by #N" or rebase second to drop overlap hunk`
`PRED.k323.evidence=#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09`
`PRED.k324.signal=agent-terminates-mid-monitor`
`PRED.k324.k095-restatement=k095 confirmed shape: agent reports "waiting for monitor" / "tests still running" then terminates`
`PRED.k324.cure=verify via gh api on every agent-completion notification; never trust narrative`
`PRED.k324.poll-shape=gh pr view <n> --json mergeStateStatus,statusCheckRollup + pulls/<n>/reviews + graphql reviewThreads + issues/<n>/comments tail`
`PRED.k324.evidence=2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262`
`PRED.k325.signal=worktree-branch-lock-on-force-push`
`PRED.k325.shape=git checkout <branch> errors "already used by worktree at <agent-worktree>"`
`PRED.k325.cure=detached-HEAD: git checkout --detach $(git ls-remote origin <branch>); modify; commit; git push --force-with-lease=<branch>:<remote-sha> origin HEAD:refs/heads/<branch>`
`PRED.k325.cleanup=git worktree remove --force <path> for aged agent worktrees`
`PRED.k325.evidence=2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD`
`PRED.k326.signal=brief-contradicts-canonical-doc`
`PRED.k326.shape=N parallel agents amplify a single brief-vs-doc contradiction into N violations`
`PRED.k326.cure=quote canonical doc verbatim in brief; mentally simulate "if all N agents follow this brief literally, do they violate any rule?"`
`PRED.k326.evidence=2026-05-09 brief "k040 — update CHANGELOG.md" → 5 of 8 agents violated CONTRIBUTING.md L110`
`PRED.k327.signal=cr-ack-vs-real-review`
`PRED.k327.ack-shape=body "✅ Actions performed - Full review triggered"`
`PRED.k327.real-review-shape=body starts "Actionable comments posted: N" OR "[!CAUTION] Some comments are outside the diff"`
`PRED.k327.distinguish-key=len(pulls/<n>/reviews) — ack=0, real=≥1`
`PRED.k327.cooldown-normal=[5s, 410s]`
`PRED.k327.cooldown-throttled=k322`
`PRED.k328.signal=pr-template-typed-heading-required`
`PRED.k328.canonical-source=CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)`
`PRED.k328.k100-restatement=heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR`
`PRED.k328.audit-list=[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]`
`PRED.k329.signal=changeset-fragment-canonical-shape`
`PRED.k329.canonical-source=CONTRIBUTING.md L196-202 + .changeset/README.md`
`PRED.k329.filename=.changeset/<adj>-<noun>-<noun>.md`
`PRED.k329.frontmatter=---\\ntype: <Added|Changed|Deprecated|Removed|Fixed|Security>\\npr: <NNN>\\n---`
`PRED.k329.body=**<Bold user-visible change>** — <symptom-led explanation>. (#<NNN>)`
`PRED.k329.observed-clean=#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows`
`PRED.k330.signal=mempalace-diary-not-callable-by-ai`
`PRED.k330.shape=mempalace MCP tools require explicit user call; AI cannot trigger`
`PRED.k330.fallback=append predicate-format findings directly to CONTEXT.md`
`PRED.k331.signal=close-with-no-comment-is-literal`
`PRED.k331.shape=instruction "close with no comment (rationale)" — parenthetical is rationale, NOT comment body`
`PRED.k331.k101-restatement=k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body`
`PRED.k331.cure=gh pr close <n> with NO --comment flag`
`PRED.k331.recovery=if violation lands, gh api -X DELETE repos/<o>/<r>/issues/comments/<id>`
`PRED.k331.evidence=2026-05-09 wave-3: violation on #3300 close, deleted within 30s`
`PROC.AGENT-DISPATCH.preflight=[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]`
`PROC.AGENT-DISPATCH.parallel-overlap-audit=before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners`
`PROC.AGENT-DISPATCH.completion-verify=run k324.poll-shape on every agent-completion notification`
`PROC.MERGE-WAVE.ordering=[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]`
`PROC.MERGE-WAVE.preflight=gh pr view <n> --json files for every PR; identify overlap pairs; surface to maintainer`
`PROC.MERGE-WAVE.changelog-strip-pattern=detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease`
`PROC.MERGE-WAVE.merge-tool=gh pr merge <n> --squash --delete-branch`
`PROC.MERGE-WAVE.merge-tool-warning=delete-branch may fail with "used by worktree at" — harmless; remote branch still deleted`
## Triage and merge-wave lessons
`WAVE.LESSON.changelog-policy-violation-multiplier=brief contradicting CONTRIBUTING.md's changelog-fragment policy ("CHANGELOG Entries — Drop a Fragment" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture`
`WAVE.LESSON.cr-throttle-burst-correlation=8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)`
`WAVE.LESSON.sibling-audit-overlap=k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap`
`WAVE.LESSON.agent-narrative-unreliable=k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification`
`WAVE.LESSON.k101-still-trips=even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard`
---
## Defect anti-patterns and fix-forwards
`RULESET.GENERATIVE-FIX=parallel implementations diverge silently when no parity test enforces equality at the test layer; for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge; exemplar: tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher)`
`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)`
---
## Shell Command Projection Module (expanded glossary entry, 2026-05-13)
Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (execGit, execNpm, execTool, probeTty, isSpawnTimeout), Windows binary resolution (resolveExecutableBinary, projectSpawnInvocation), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. WINDOWS BINARY RESOLUTION — which file a declared command name actually names, and what must be handed to `spawnSync` to start it — is owned here as of #3411 (epic #3411 Phase 1), which found the seam declaration untrue for this axis: four divergent implementations had grown outside it (`execNpm`'s `shell:true`, `execTool`'s absence of any handling, a private PATH+PATHEXT scan in `gsd-core/bin/gsd-tools.cjs`, and a fourth candidate-extension array in `fallow-runner.cts`), and #3275's fix to one provably never reached the others. `resolveExecutableBinary(name, {platform, env})` returns the RESOLVED PATH (the prior `hasBinary` computed it and threw it away): on win32 it tries PATHEXT entries ONLY and never the bare name, because npm global installs drop an extensionless POSIX `sh` shim beside `foo.CMD` and a bare-name-first scan resolves to it, leaving the ENOENT unchanged (#3275); a name already carrying a PATHEXT-listed extension is tried as-is BEFORE the append loop, and a suffix outside PATHEXT is not an extension. On POSIX it answers existence only, and `execTool` does not consult it there at all — the bare name goes to `spawnSync` unchanged and Node's own PATH search does the work, keeping macOS/Linux byte-identical for a symbol whose blast radius is CRITICAL (167 affected symbols, 53 files). `projectSpawnInvocation(command, args, {platform, env})` is the inseparable second half: `CreateProcess` cannot execute a `.cmd`/`.bat` at all, so resolution alone does not fix Windows, and exporting only the resolver would leave every caller to re-derive the mediation — which is how the four copies accumulated. It mediates through `ComSpec` with an EXPLICIT argv array (`/d /s /c`), never `shell: true`: that is CVE-2024-27980's argument-injection vector and Node 26's DEP0190. Mediation keys on the target — the resolved path, or the declared name when resolution found nothing — so a `.cmd` resident in the current directory (which PATH-only resolution misses but `cmd.exe /c` still finds) keeps working, while a BARE unresolved name is passed through untouched so the spawn fails with ENOENT rather than cmd.exe's exit 9009, preserving `_spawnResult`'s `'<declared>: not found'` contract. `gsd-core/bin/gsd-tools.cjs`'s `resolveSpawnBinary` is the `bin/` entry point onto this and holds no copy of the logic. CALLERS CHOOSE HOW MUCH OF THE PROJECTION TO ADOPT, and the asymmetry is deliberate: `windowsVerbatimArguments` marks the cases where mediation was REQUIRED (the caller must take `command` and `args` together, or the batch file cannot start at all), whereas a merely-RESOLVED path is an offer a caller may decline. `execTool` declines it and keeps spawning the DECLARED name — libuv's `CreateProcess` path already performs PATH+PATHEXT search, so resolving a `.exe` there buys nothing while changing what 167 dependents observe being spawned; `tests/graphify.test.cjs` pins that contract by spying on `spawnSync`'s first argument, and the Windows CI lane caught the violation when `execTool` briefly adopted the resolved path (`python3` arriving as `C:\…\python3.EXE`). `deps.spawn` accepts it, because that lane's `hasBinary` probe answers from the same resolver and probe and spawn must agree on the exact file (#3445). Env lookups go through a case-insensitive read: Windows names the variable `Path`, `process.env` is a case-insensitive proxy that hides this, and `execTool`'s `{...process.env, ...opts.env}` spread produces a PLAIN object that keeps the OS casing and loses the proxy — an exact-case `env['PATH']` there returns undefined and the scan silently sees nothing. This is fixed inside the seam by `envGet(env, name)` (formerly private `_envGet`, exported as of #3624), and ratcheted outside the seam by `local/no-exact-case-env-access` (ADR-1703 catalog, epic #3411 Phase 4): it flags a read of any casing of `PATH`/`PATHEXT`/`ComSpec`/`USERPROFILE`/`TEMP`/`TMP`/`APPDATA` off any receiver that is not literally `process.env`, matched via an "env-shaped receiver" check (`<expr>.env`/`<expr>['env']` or a bare `env`-named identifier) rather than a blanket case-insensitive property-name match — the blanket form produced 113 false positives against ordinary `.path`-named properties elsewhere in the tree. `DEFECT.WINDOWS-EXACT-CASE-ENV-ACCESS`. `resolveExecutableBinary` carries two OPT-IN options, both defaulting off so Phase 1's callers are byte-identical: `prependPaths` (directories searched before `env.PATH`, in order, with the identical per-directory candidate logic — this is how `node_modules/.bin`-first precedence is expressed without env surgery) and `requireExecutable` (POSIX-only additional `accessSync(X_OK)`; a no-op on win32, where mode bits do not mean execute). `requireExecutable` is opt-in rather than default because making it unconditional would break #3445's own suite, which stages candidates with plain `writeFileSync` and never sets an exec bit — the repo bans `chmod` in tests — so every one of those would resolve to null on POSIX. As of #3618 (epic #3411 Phase 2) `src/fallow-runner.cts` holds no resolver of its own: `resolveFallowBinary` is one seam call passing `prependPaths: [<cwd>/node_modules/.bin]` and `requireExecutable: true`, preserving `.bin`-before-PATH precedence and the POSIX executability check. Its prior win32 candidate list ended in a BARE `fallow`; the seam does not, and dropping it is the fix — an extensionless file beside `fallow.cmd` is npm's POSIX `sh` shim that `CreateProcess` cannot run (#3275). Note the precedence was documented BACKWARDS (`PATH` then `.bin`) in `structural-pre-pass.md` and four INVENTORY translations until #3618 corrected them; the code was always `.bin` first. As of #3619 (epic #3411 Phase 3) the seam's ownership is RATCHETED by `local/no-private-binary-resolution` (`eslint-rules/no-private-binary-resolution.cjs`, ADR-1703 catalog): re-implementing Windows binary resolution outside `src/shell-command-projection.cts` is an eslint error, keyed on the two unambiguous signals — reading `PATHEXT` in any casing from any object, and a hardcoded list carrying two or more of `.exe`/`.cmd`/`.bat`/`.com` (the shapes all four deleted resolvers actually had). `DEFECT.WINDOWS-PRIVATE-BINARY-RESOLUTION`. It deliberately does NOT flag a bare-name spawn — ~30 such sites exist and none is a defect, since `git`/`gh`/`npm` ship native `.exe` that `CreateProcess` resolves unaided — nor a `PATH` scan, which is indistinguishable from a legitimate membership check (`bin/install.js`). The extension threshold is TWO because a single `.endsWith('.cmd')` is a classification, not a candidate set (`runtime-hooks-surface.cts` derives `.cmd` shim paths that way), and matching is boundary-aware because a naive substring test flags `.execute` and `.compacting`. The seam exemption is path-SUFFIX anchored, not substring; the rule's own surface is `src/**/*.cts`, `gsd-core/bin/**/*.cjs`, `scripts/**/*.cjs`, and `hooks/**/*.js`, and `tests/**` is deliberately outside that surface — test setup legitimately assigns `process.env.PATHEXT` (`tests/fallow-runner.test.cjs`'s P3 case), so `tests/shell-command-projection-dispatch.test.cjs` is NOT linted by this rule at all; the suffix-vs-substring distinction is instead proven by RuleTester case I9's synthetic filename, not by real-world coverage of that test file. `eslint-rules/**` is outside the rule's globs entirely rather than exempted, because `lib/portability-vocab.cjs` owns the extension set. To make the ratchet strict with no carve-out, `resolveExecutableBinary` also gained `pathOverride` — "search THIS PATH, read everything else including PATHEXT from the ambient environment" — so `resolveFallowBinary` supplies its own search path without hand-threading PATHEXT, which would itself have been a private PATHEXT read. `pathOverride: ''` means an EMPTY search path, never a fallback to `env.PATH` (`!== undefined`, not truthiness). `isSpawnTimeout` is the single shared "did this subprocess time out" predicate (error.code==='ETIMEDOUT' only — cross-platform-safe; does not require signal==='SIGTERM'), consumed by worktree-safety.cts, worktree-base-ref.cts, commands.cts, and this module's own dispatchGsdCommand (#3050 — "Generative Fix Divergence"). `projectPathExportLine(targetDir)` is the single source of the `export PATH="<dir>:$PATH"` line for all three PATH-persistence lanes (repair, persist, win32 Git Bash) — it double-quote-escapes for the line's final rc-file context before any lane single-quotes it for its own `echo` transport, closing the #3118 command-substitution injection where a lane re-escaped the line itself and let a `$(…)` in the target dir execute on every new shell; the win32 cmd.exe lane fails closed (empty actions) whenever the target dir contains `"`, since that character is reserved on Windows and would otherwise close cmd's quoted region, and now tags that empty result with a typed `PATH_ACTION_REASON` (`win32_reserved_quote`) so it stays distinguishable from the unrelated "no target directory given" empty result (`no_target_dir`, #3118); the fish lane emits `fish_add_path -- '<dir>'` (`--` end-of-options separator, verified empirically against fish 4.8.1), since a leading-dash target dir is otherwise misparsed by fish's argparse-based option scanning regardless of quoting; `escapeTomlDoubleQuotedString` now escapes TOML's required control characters (U+0000-U+0008, U+000A-U+001F, U+007F), not just backslash and quote, closing a raw-newline/CR/NUL config.toml parse failure (#3118). Lives in `gsd-core/bin/lib/shell-command-projection.cjs`. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
`SEAM.shellcmdproj-win-binary-resolution.owns=Windows binary resolution (resolveExecutableBinary, projectSpawnInvocation) — which file a declared command name actually names, and cmd.exe mediation`
`SEAM.shellcmdproj-win-binary-resolution.enforced-by=lint-rule:no-private-binary-resolution`
Invariants:
- Result shape: all exec* functions return `{ exitCode, stdout, stderr }`; never throw on non-zero exit code.
- Platform policy owned at the seam: `shell: process.platform === 'win32'` lives only in execNpm; probeTty returns `null` on Windows.
- Normalization policy: platformWriteSync owns full `normalizeMd` for `.md`; CRLF-to-LF + trailing newline for all others; callers must NOT pre-call `normalizeMd`.
- `_normalizeMd` is re-implemented inline (not imported from `core.cjs`) to avoid circular dep.
- `atomicWriteFileSync`, `safeReadFile`, `normalizeMd` were duplicated wrappers in `core.cjs`, removed from there in this module's own Migration Phase 4 (`#3468`, see ADR-0009/ADR-0010) — a separate effort from epic #1267 (the core.cjs re-export-spine retirement); callers now import `platformWriteSync`/`platformReadSync`/`normalizeContent` from this module directly.
Migration: Phases 1-4 (#3465-#3468) shipped 2026-05-13 — seam additions, subprocess-dispatch migration (6 files), fs migration (15 files / 215 call sites), and core.cjs compat-export removal are all complete; see ADR-0009's 2026-05-13 Update section.
Dependency posture: this seam OWNS its Windows binary resolution and cmd.exe mediation rather than delegating to `cross-spawn`, `nano-spawn`, or `execa` — evaluated and decided in ADR-3625 (#3625). Do not re-litigate it in a PR; read the ADR. Headlines: `nano-spawn` is async-only and cannot back a `spawnSync` seam; `cross-spawn` is the only structurally-eligible candidate and independently arrives at the SAME escaping mechanism this seam uses (`cmd.exe /d /s /c` + pre-escaped line + `windowsVerbatimArguments`), which validates the approach rather than superseding it — but it resolves `process.cwd()` FIRST on Windows even when an explicit PATH is supplied (a binary-planting surface), calls `process.chdir()` during resolution, and keys its escape depth on a `node_modules/.bin/*.cmd` path regex of the same fragile shape #3411 was filed to delete. Adoption would also break `execTool`'s observable not-found contract across 53 dependent files. The decision carries revisit-if conditions (notably: if the seam ever needs an async dispatch shape, re-run the comparison — the honest sync→async ripple is 14 symbols / 7 files upstream, NOT the 167/53 figure, which is a `direction:both` measurement inflated by downstream callees).
---
## Session log (chronological, append-only, one line per session)
> **Discipline**: new operational lessons go into a predicate above. Each
> dated entry below is a one-line pointer at the predicates derived from
> that session — NOT a prose narrative. If you can't compress a session's
> lesson into a predicate, the lesson isn't sharp enough yet — keep
> grinding.
`SESSION.2026-05-05=[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]`
`SESSION.2026-05-05.sdk-bridge=PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary`
`SESSION.2026-05-09=[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]`
`SESSION.2026-05-10=[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]`
`SESSION.2026-05-13=[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]`
`SESSION.2026-05-14=[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]`
`SESSION.2026-05-15=[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]`
`SESSION.2026-05-15.parallel-fix-dispatch=[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]`
`SESSION.2026-05-16=[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with "Failed to read config.json:"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]`
`RULESET.HARNESS.test-memory-guard=~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM`
`RULESET.PR-FLOW.docker-before-push=before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — "we don't set a timer we actively watch and record results in real time as possible". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:"passed" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.`
`RULESET.PR-FLOW.templates-mandatory=every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — "the whole reason i have that github action is because you fucking blow through and ignore using the templates"`
---
## Executor failure classification (#3095 / PR #3490)
`EXEC.CLASSIFY.handler=gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)`
`EXEC.CLASSIFY.workflow=gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)`
`EXEC.CLASSIFY.classes={class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}`
`EXEC.CLASSIFY.sentinel-order=most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form`
`EXEC.CLASSIFY.cross-runtime=Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests`
`EXEC.CLASSIFY.precedence=quota sentinel wins over classifyHandoffIfNeeded bug when both appear`
`EXEC.CLASSIFY.retry-after-parser=\bretry[-_ ]after[:\s]+(\d+)\b avoids embedded-word false matches like noretry-after`
`EXEC.CLASSIFY.proactive-signal-not-usable=Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)`
`PROC.PARALLEL-FIX-DISPATCH.pattern=bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill`
`PROC.PARALLEL-FIX-DISPATCH.rationale=long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION`
`PROC.PARALLEL-FIX-DISPATCH.observed=#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened`
`PROC.TRIAGE.routing-incoming=stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest`
`PROC.TRIAGE.comment-shape=lead with "duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close`
`PROC.TRIAGE.no-duplicate-label=this repo has no duplicate label; framing lives in comment text + closing the issue`
---
## PR fix discipline — patterns observed 2026-05-23
Full detail in `~/.claude/skills/gsd-pr-fix-discipline/SKILL.md`. AI agents MUST check this section before pushing to `open-gsd/gsd-core`.
### INVENTORY / manifest drift
- **Symptom:** `tests/inventory-manifest-sync.test.cjs` fails — `"New surfaces not in manifest"` (the MANIFEST half) or `"Shipped surfaces in docs/INVENTORY-MANIFEST.json with NO row in docs/INVENTORY.md"` (the ROSTER half, #3762); or `tests/inventory-headings-countfree.test.cjs` fails if a `(N shipped)` count was re-added to a heading
- **Affected this session:** #154, #156, #143, #155, #169
- **Fix:** Add row to `docs/INVENTORY.md` + `node scripts/gen-inventory-manifest.cjs --write`. The two halves have DIFFERENT remedies and the roster half cannot be regenerated — a role sentence is hand-written by design, so re-running the generator never clears it.
### Slash command two-tier confusion
- **Symptom:** `tests/slash-command-namespace.test.cjs` (folds former `bug-2543-gsd-slash-namespace`, consolidation epic #1969) or `tests/init-manager.test.cjs` (folds former `bug-3584-runtime-slash-emitters`, consolidation epic #1969) fails
- **Affected this session:** #154 (three passes), #164 (added the authoritative matrix)
- **Fix:** Consult `## Slash-command form` section of this file before touching any `/gsd-` or `/gsd:` token — colon for `agents/`/`commands/`, hyphen for runtime emitters
### Concurrency cancel-in-progress masking real CI state
- **Symptom:** `gh pr checks` shows failures but the latest commit SHA's run was cancelled before Tests even started
- **Affected this session:** #154, #136
- **Fix:** `gh workflow run Tests --repo open-gsd/gsd-core --ref <branch>`; verify with `gh run list --branch <branch> --workflow Tests --limit 1 --json status,conclusion,headSha`
### Missing changeset fragment
- **Symptom:** `changeset-lint` fails with `fail_missing_fragment` (~5s)
- **Affected this session:** #156, #143, #164
- **Fix:** `node scripts/changeset/new.cjs --type <Type> --pr <N> --body "..."` or apply `no-changelog` label for doc-only PRs
### Cross-platform Windows / Node 24 hazards
- **Symptom:** Windows CI leg fails; Mac/Linux green — POSIX paths in `node -e`, hardcoded `.nvmrc` fixtures, 2000ms wall-clock budget flakes, `synckit` uncaught Worker exception
- **Affected this session:** #157
- **Fix:** Use `./package.json` not `$PWD/package.json`; write `.nvmrc` dynamically in `before()` hook; use 5000ms budget; wrap `getExecuteForCjs()` in `try/catch`
### Sub-agent rubber-duck stall
- **Symptom:** Sub-agent returns a question list and halts; no commits or push in the worktree
- **Affected this session:** Multiple agents mid-session
- **Fix:** Every sub-agent brief must include: `Skill rubber-duck is BANNED in this sub-agent. Convert to internal monologue and proceed.`
### Stacked PR squash-merge breakage
- **Symptom:** After base PR squash-merges, stacked PR shows conflicts or wrong diff; GitHub auto-retarget fails
- **Affected this session:** #158 stacked on #156
- **Fix:** `git rebase --onto next <old-base> <stacked-branch>` then force-push and `gh pr edit --base next` (repo's integration branch as of the `next`-branch model introduced 2026-05-24; was `main` before that)
### `tee` pipe swallowing exit codes
- **Symptom:** piping any test-runner output through `tee` (e.g. `... 2>&1 | tee /tmp/log`) returns `0` even when the run reports failures — the pipeline exits on `tee`'s status, not the runner's
- **Affected this session:** Session-wide risk
- **Fix:** Run `gsd-test` (classic executor) UNPIPED per CLAUDE.md, or `set -o pipefail` before any pipe. Do not use the deprecated `gsd-test-summary` / `gsd-test-both` wrappers at all (see DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION / DEFECT.GSD-TEST-MIRROR-POISONED).
### Auto-merge disabled
- **Symptom:** `gh pr merge --auto` returns `GraphQL: Auto merge is not allowed for this repository`
- **Affected this session:** All stacked PRs
- **Fix:** Merge manually by hand in dependency order once CI greens; `gh pr merge <N> --squash --repo open-gsd/gsd-core`
### Defect enforcement (ADR-2143 follow-on)
Prose defect entries retired in favour of gates; the gate IS the record. `DEFECT.UNBOUNDED-SUBPROCESS` → `eslint-rules/require-subprocess-timeout.cjs` (error, `src/**`; options literal must carry `timeout` — git 5-30s, npm 60s; the rule only requires the call be bounded, not what the caller does after — 7 of the 8 sites this surfaced degrade to an empty/false/null result on failure, and the 1 that guards a destructive real-run migration (`roadmap-upgrade.cts`'s pre-mutation clean-tree check) correctly still throws rather than proceed against an unverified working tree). `DEFECT.CANARY-VERSION-LEAK` → `scripts/lint-canary-version-leak.cjs` + the `canary-version-leak` job in `.github/workflows/version-gate.yml` (PRs whose base is `main`). `DEFECT.CHANGESET-PR-FIELD-DRIFT` → `findPrFieldDrift` in `scripts/changeset/lint.cjs`. Entries whose condition no automated check can evaluate were deleted rather than kept as unenforceable prose. `DEFECT.FRONTMATTER-SCALAR-BROAD-GREP` → `scripts/lint-frontmatter-scalar-broad-grep.cjs` (in `lint:ci`; flags an unscoped `grep "^key:"` over a whole planning doc with no frontmatter slice and no `-m1`/`head -1` guard). `DEFECT.REMOVED-BUT-NEEDED` → `scripts/lint-removed-but-needed.cjs` (in `lint:ci`; a deleted file whose basename still appears in `.github/workflows/`, `gsd-core/`, `docs/` or `package.json`; and since #3565, in `tests/` behind a `pins-existence` vs `asserts-absence` discriminator — `fs.existsSync`/`readFileSync`/`require`/quoted-object-key on the deleted basename fails, a negated `!…includes`/`!fs.existsSync` absence assertion is the correct post-deletion state and passes; a bare mention that is neither is not flagged, because the undiscriminated widening was tried in #3560 and reverted for firing on the very tests that prove a deletion worked). `DEFECT.DEFAULT-FLIP-DOCUMENTATION` → `scripts/lint-default-flip-documentation.cjs` + `.github/workflows/default-flip-documentation.yml`, covering `gsd-core/bin/shared/config-defaults.manifest.json` only: a changed value for an EXISTING key requires a `## Breaking Changes` PR section. The six Windows-portability entries (`WINDOWS-TEST-PORTABILITY`, `WINDOWS-POSIX-MODE-BIT-ASSERT`, `WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT`, `WINDOWS-PATH-LITERAL-IN-ASSERT`, `WINDOWS-FS-OPS`, `TEST-SHELL-PIPELINE-NONPORTABLE`) were already enforced by ADR-1703's own `local/*` AST ESLint rules (`no-posix-mode-bit-assert`, `normalize-path-in-content`, `no-path-literal-in-assert`, `require-fs-op-fallback`, `no-crlf-fragile-split`, `no-unguarded-nonportable-exec` — see `eslint.config.mjs`) before this PR; their prose duplicated the rules' own doc comments, so it was deleted rather than kept as a second copy. **Residual, deliberately unenforced:** `buildNewProjectConfig`'s hardcoded literal in `src/config.cts` has env-derived branches and `CONFIG_DEFAULTS` spreads, so no reliable resolved-value diff exists without executing the compiled module at both refs — a line-diff there false-fires on any refactor that merely moves the object, so it was left unchecked rather than shipped noisy.