Merge branch 'next' into fix/1698-codex-output-last-message

This commit is contained in:
Tom Boucher
2026-06-30 10:04:59 -04:00
committed by GitHub
127 changed files with 4916 additions and 576 deletions

View File

@@ -0,0 +1,6 @@
---
type: Fixed
pr: 1819
---
**`phase.complete` no longer reports a false `is_last_phase` on a `<details>`-wrapped checkbox checklist (#1591, #1752)** — when the active milestone's phase checklist was written as `- [ ] Phase N:` checkbox items inside a `<details>` block and the next phase had no directory on disk yet (still in planning), `phase.complete`'s `isLastPhase` roadmap-enumeration fallback used a heading-only pattern (`/#{2,4}\s*Phase…/`) that never matched checkbox items. It returned `is_last_phase: true, next_phase: null` on a mid-milestone phase and — via the milestone-complete cascade — wrongly flipped STATE.md to `Milestone complete` and decremented `progress.total_phases` (e.g. 8 → 7). The pattern now matches both heading-style (`### Phase N:`) and checkbox-list phases (`- [ ] Phase N:` / `- [x] Phase N:`); `extractCurrentMilestone` already surfaces the `<details>`-wrapped checklist correctly, so no parser change was needed. Only the reproduced `phase.complete` fallback is changed; the heading-only sibling patterns elsewhere in `phase.cts` are untouched.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1814
---
**`/gsd-settings` no longer warns about four search-provider keys on fresh projects (#1747)** — `buildNewProjectConfig` emits seven search-provider availability flags and `research-provider.cts` `providerAvailability()` consumes all seven, but only three were registered in `VALID_CONFIG_KEYS` (`config-schema.manifest.json`). Running `/gsd-settings` on a freshly generated `.planning/config.json` printed `unknown config key(s) … tavily_search, ref_search, perplexity, jina — these will be ignored` even though the user never hand-edited the config. The four missing keys are now registered alongside `brave_search`/`firecrawl`/`exa_search` and documented in `docs/CONFIGURATION.md`; a drift guard in `tests/bug-2530-valid-config-keys.test.cjs` now requires every config-driven research-provider flag to be in the schema, so a future provider addition cannot reintroduce the drift.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1818
---
**`gsd-tools state json` no longer reports conflated progress for an unversioned milestone (#1761)** — the ADR-1769 Phase 7 fix (#1794) taught `state sync` to leave Progress untouched when a milestone version is asserted but the ROADMAP has no versioned heading for it, but the `state json` **read** path still rebuilt progress via `buildStateFrontmatter`, whose phase-heading count fell back to the whole document and summed sibling milestones. `state json` therefore reported a conflated `total_phases` (e.g. 8 = 4+4 across two milestones) plus a derived `percent`, contradicting the sync guard on the very same project. The read path now mirrors the sync guard: when the asserted milestone cannot be bounded to a versioned ROADMAP heading, `total_phases` falls back to the on-disk phase-dir count and `percent` is omitted. Bounded milestones (versioned ROADMAP, or no milestone asserted) are unchanged; the signal rides on the existing `_diskScanCache` so `extractCurrentMilestone`'s return contract and its other callers are untouched.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1815
---
**`gsd-graphify-update.sh` now reads the full multi-line command in Gate 2 (#1772)** — the PostToolUse auto-update hook joined `tool_name` + `\n` + `tool_input.command` and extracted the command with `sed -n '2p'` (line 2 only). Agent runtimes (Claude Code's Bash tool among them) routinely emit HEAD-advancing commits as multi-line scripts (`cd /path`, then `git add`, then `git commit …`), so line 2 was the `cd`, Gate 2's `*"git commit"*` match failed, and the rebuild silently no-op'd on real commits even with `graphify.auto_update: true`. The failure was invisible in manual probes because a single-line `git commit -m x` passes line 2 verbatim. The hook now captures line 2 through EOF (`sed -n '2,$p'`) so the `case` glob sees the full command string; single-line behavior is unchanged and multi-line commands without a HEAD-advancing op still no-op cleanly.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1816
---
**`/gsd-thread close|resume` now writes the thread status/updated frontmatter (#1778)** — the thread workflow's CLOSE and RESUME branches invoked `frontmatter.set` with the pre-1.6 fully-positional shape (`frontmatter.set <file> <field> <value>`), but since 1.6 the dispatcher parses the file positionally and reads `field`/`value` from the named flags `--field`/`--value` via `parseNamedArgs`. The positional form left `field`/`value` undefined, `cmdFrontmatterSet` errored `file, field, and value required`, and the writes were silently skipped — so closing a thread never marked it `status: resolved` and resuming never marked it `status: in_progress`, with the error scrolling past on every thread command. All four sites (CLOSE `status`+`updated`, RESUME `status`+`updated`) now use the 1.6 hybrid form that `verify-work.md` already uses (`frontmatter.set <file> --field <field> --value <value>`).

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1830
---
**`gsd-tools state rebuild`** — new subcommand that re-derives STATE.md body structure from canonical sources (frontmatter + `.planning/phases/` disk scan), reconciling drifted `## Current Position` prose, dropping orphaned rows from the `**By Phase:**` table, clearing template-placeholder field values, and de-duplicating `## Session Continuity Archive` blocks. Every mutation is recorded in a `## Rebuild Log` audit section. Idempotent (running twice on a clean file is a no-op). Supports `--dry-run` (preview) and `--verbose` (tee log to stderr). Heavier, manual counterpart to the lightweight auto-triggered `state sync`.

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1813
---
**Internal: the installer's `program` (display-name) + `command` (slash-invocation) chains are now single-source lookups** — the 14-line `program` chain (an exact duplicate of `runtimeLabel`) → `getRuntimeLabel`, and the 14-line `command` chain (the per-runtime `/gsd-new-project` syntax: gemini `/gsd:`, codex `$`, cursor skill-mention, kimi `/skill:`, default `/gsd-new-project`) → new `getRuntimeNewProjectCommand(runtime)` helper (ADR-1239 Phase B / #1679 AC2 slice 4). `runtime ===` count in `bin/install.js`: 53 → 25 (cumulative this session: 129 → 25). Stdout strings preserved byte-for-byte; no install-output change (golden-parity 16/16). No user-facing change.
<!-- docs-exempt: internal refactor; no user-facing doc surface -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1811
---
**Internal: the installer's per-function `is<Runtime>` flag-declaration blocks are now a single `runtimeFlags` lookup** — the four duplicated `const isX = runtime === 'x'` blocks in `bin/install.js` (uninstall / writeManager / install / a fourth helper — 48 branches) are collapsed into one `runtimeFlags(runtime)` helper in `runtime-name-policy.cts` (ADR-1239 Phase B / #1679 AC2 slice 3). The add-a-host tax for flags is removed (one `RUNTIME_FLAG_IDS` entry, not four declaration blocks). Install output is byte-identical for all 16 runtimes (golden-parity asserted); `runtime ===` count in `bin/install.js`: 101 → 53. No user-facing change.
<!-- docs-exempt: internal refactor; no user-facing doc surface -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1808
---
**Internal: third-party descriptor loader enforces `configHome` write-confinement at load time** — `loadRegistry({includeInstalled:true, configHome})` now rejects (skip + warn, fail-closed) any installed third-party host-plugin descriptor whose declared `destSubpath` resolves outside the supplied `configHome`, before it is composed into the registry (ADR-1239 Phase C-2 / #1681 slice 2). The `configHome` option is optional and backward-compatible (omitted → no load-time check; install-time gate still bounds writes). No user-facing change for existing flows.
<!-- docs-exempt: internal loader hardening; no user-facing doc surface until slice 3's MCP server -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1809
---
**Internal: companion MCP server module (interface points 1 + 5)** — `handleMessage`/`runServer` (new `src/mcp-server.cts`) is a minimal, dependency-free stdio JSON-RPC 2.0 server exposing `gsd_invoke_command` (→ the command-routing hub) + `gsd_read_state`/`gsd_write_state` (→ the Phase 3 stateIO seam), so any MCP-consuming host can drive GSD with no bespoke plugin (ADR-1239 Phase C-2 / #1681 slice 3a). Bin entry / packaging deferred to slice 3b. No user-facing change — the server is not yet wired to a bin entry.
<!-- docs-exempt: internal server module; user-facing doc lands with slice 3b's bin entry -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1804
---
**Internal: the model adapter seam exposes `passive` + `active` adapters selected by `modelMode`** — `createModelAdapter({modelMode})` (new `src/model-adapter.cts`): `passive` formalizes today's tier routing (delegates to `model-resolver.resolveModelForTier`), `active` is a host-supplied `sendRequest` seam (VS Code `vscode.lm` / pi providers), fail-closed until Phase 5 binds a concrete provider (ADR-1239 Phase C-1 / #1680 AC3). No user-facing change — the seam is not yet wired to any runtime path.
<!-- docs-exempt: internal adapter seam; no user-facing command/flag/config/schema/doc surface -->

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1810
---
**`gsd-mcp-server` — companion MCP server (interface points 1 + 5)** — a new bin command (`npx @opengsd/gsd-core gsd-mcp-server`) runs a stdio JSON-RPC 2.0 MCP server exposing `gsd_invoke_command` (→ the GSD command-routing hub) + `gsd_read_state` / `gsd_write_state` (→ `.planning/` state), so any MCP-consuming host (Claude Code, Codex, OpenCode, VS Code, Gemini CLI, Cursor, Cline, Hermes) can drive GSD with no bespoke plugin (ADR-1239 Phase C-2 / #1681). Dependency-free (hand-rolled JSON-RPC). How-to: `docs/how-to/connect-gsd-mcp-server.md`.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 1738
---
**Honest verifier — verify-phase now abstains on non-inferable `backstop` truths instead of confidently false-passing them (#1154).** When the spec's edge-probe marks a truth non-inferable (`verification: backstop`) and the verifier cannot confirm it with explicit evidence (a passing wired held-out/property test, or a directly-observed behavior), it now reports `human_needed` with reason `insufficient_spec` ("unverified — held-out test recommended") rather than a silent `passed`. Autonomous runs complete with "N unverified non-inferable checks"; interactive runs route to the end-of-phase human checkpoint. Inferable truths are never abstained (over-abstention guard); abstention is exogenous (driven by the tag, not self-judgment). Truth-axis mirror of the prohibition judgment-tier (ADR-550 D4).

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1805
---
**Internal: hook-bus + stateIO adapter seams** — `createHookBus({bus})` (new `src/hook-bus.cts`, `host`/`engine`/`none` — engine is in-process pub/sub, host fail-closed, none silent) + `createStateIO({io})` (new `src/state-io.cts`, `filesystem`/`sandboxed-storage`/`session-log-append` — filesystem delegates to fs, the rest are fail-closed seams) (ADR-1239 Phase C-1 / #1680 AC4). Completes the Phase 3 adapter seam layer; concrete host binding is Phase 5. No user-facing change.
<!-- docs-exempt: internal adapter seams; no user-facing command/flag/config/schema/doc surface -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1806
---
**Internal: external-descriptor trust gate — load-time `configHome` confinement** — `assertDescriptorConfined(descriptor, configHome)` (new `src/external-descriptor-trust.cts`) fail-closed rejects any installed third-party host-plugin descriptor whose declared `destSubpath` resolves outside the user-approved `configHome`, before its install plan runs (ADR-1239 Phase C-2 / #1681 slice 1). Defense-in-depth load-time twin of Phase 2's install-time `assertDestWithinConfigHome`. Not yet wired into the loader (slice 2). No user-facing change.
<!-- docs-exempt: internal security module; no user-facing doc surface until the loader wiring in slice 2 -->

View File

@@ -1,7 +1,7 @@
{
"name": "gsd-core",
"displayName": "GSD Core",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
"author": {
"name": "open-gsd",

View File

@@ -319,7 +319,7 @@ jobs:
needs: [validate-version, install-smoke-rc]
if: inputs.action == 'rc'
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 30
permissions:
contents: write
id-token: write

View File

@@ -53,7 +53,7 @@ Module owning projection from dispatch results/errors to CLI `{ exitCode, stdout
Module owning STATE.md parse, field extraction, field replacement, status normalization, and frontmatter reconstruction. 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`.
### 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`). 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` and the direct `writeStateMd` callers in `milestone.cts:352` and `phase.cts:1770`; verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call. 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. Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`).
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` and the direct `writeStateMd` callers in `milestone.cts:352` and `phase.cts:1770`; verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call. 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`).
### Query Execution Policy Module
Module owning query transport routing policy projection (`preferNative`, fallback policy, workstream subprocess forcing) at execution seam.
@@ -203,7 +203,7 @@ ADR-1244 D3 fetch-and-stage seam (`gsd-core/bin/lib/capability-source.cjs`). Pri
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`, generated from `src/capability-consent.cts`). 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: 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)}<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".
Issue #1459 user-owned consent seam (`gsd-core/bin/lib/capability-consent.cjs`, generated from `src/capability-consent.cts`). 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: 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`, generated from `src/capability-lock.cts`). 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).

View File

@@ -197,6 +197,7 @@ For each truth:
- A pre-existing test exercises the transition/invariant and passes (confirm via Step 7b's single-named-test path) → ✓ VERIFIED.
- No such test exists, or it can't run without a server/state mutation → ⚠️ PRESENT_BEHAVIOR_UNVERIFIED. Emit a human-verification item (Step 8) and do not count it toward the verified score (Step 9).
- An accepted override (Step 3b) carries the truth as PASSED (override), exactly as it does for a FAILED truth.
5b. **Non-inferable (`backstop`) truths:** a `verification: backstop` truth (via `truthVerification()`) abstains unless confirmed by explicit evidence — mark `insufficient_spec` -> a human-verification item -> `human_needed`. See `references/honest-verifier.md`.
6. Determine truth status
## Step 3b: Check Verification Overrides

31
bin/gsd-mcp-server.js Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
'use strict';
/**
* gsd-mcp-server — companion MCP server bin entry (ADR-1239 Phase C-2 / #1681).
*
* Lives at top-level bin/ (alongside install.js) — it is a PACKAGE bin the host
* spawns via `npx gsd-mcp-server` (or the global bin), NOT a per-runtime
* artifact copied into a host's config dir. (Placing it under gsd-core/bin/
* would leak it into every runtime install + break golden parity.)
*
* A stdio JSON-RPC 2.0 server exposing GSD interface points 1 (command) + 5
* (state IO) so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/
* Cursor/Cline/Hermes) can drive GSD with no bespoke plugin. Delegates to the
* tested server module (gsd-core/bin/lib/mcp-server.cjs runServer). Reads
* line-delimited JSON-RPC from stdin, writes one response + newline per
* request, exits cleanly when stdin closes.
*
* The protocol logic (handleMessage) + the injectable-stream loop (runServer)
* are unit-tested in tests/gsd-mcp-server.test.cjs; the process lifecycle
* (spawn → JSON-RPC → clean exit) in tests/gsd-mcp-server-bin.test.cjs.
*/
const { runServer } = require('../gsd-core/bin/lib/mcp-server.cjs');
runServer({
input: process.stdin,
output: process.stdout,
ctx: { cwd: process.cwd() },
}).catch((err) => {
process.stderr.write(String((err && err.message) || err) + '\n');
process.exit(1);
});

View File

@@ -37,7 +37,7 @@ const {
// installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
// conversion module's rewrite engine can consume it without importing
// bin/install.js. Re-exported below for back-compat consumers/tests.
const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment, runtimeFlags, getRuntimeNewProjectCommand } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const {
applyWorktreeBaseRef,
readBaseRefFromSettings,
@@ -6918,19 +6918,7 @@ const GSD_UNINSTALL_HOOKS = [
* @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot')
*/
function uninstall(isGlobal, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isGemini = runtime === 'gemini';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const isTrae = runtime === 'trae';
const isQwen = runtime === 'qwen';
const isHermes = runtime === 'hermes';
const isCodebuddy = runtime === 'codebuddy';
const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type. Cline local
@@ -7915,18 +7903,7 @@ function resolveInstallRelativePath(baseDir, relPath) {
* Write file manifest after installation for future modification detection
*/
function writeManifest(configDir, runtime = 'claude', options = {}) {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isGemini = runtime === 'gemini';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isTrae = runtime === 'trae';
const isCline = runtime === 'cline';
const isKimi = runtime === 'kimi';
const isHermes = runtime === 'hermes';
const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const gsdDir = path.join(configDir, 'gsd-core');
// #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
// commandsDir points to the old location for Gemini (which still uses commands/gsd/).
@@ -8413,21 +8390,7 @@ function reportInstallerMigrationResult(result) {
}
function install(isGlobal, runtime = 'claude', options = {}) {
const isOpencode = runtime === 'opencode';
const isGemini = runtime === 'gemini';
const isKilo = runtime === 'kilo';
const isKimi = runtime === 'kimi';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const isTrae = runtime === 'trae';
const isQwen = runtime === 'qwen';
const isHermes = runtime === 'hermes';
const isCodebuddy = runtime === 'codebuddy';
const isCline = runtime === 'cline';
const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
@@ -10433,14 +10396,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
* Apply statusline config, then print completion message
*/
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isTrae = runtime === 'trae';
const isCline = runtime === 'cline';
const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
@@ -10563,37 +10519,11 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
}
}
let program = 'Claude Code';
if (runtime === 'opencode') program = 'OpenCode';
if (runtime === 'gemini') program = 'Gemini';
if (runtime === 'kilo') program = 'Kilo';
if (runtime === 'codex') program = 'Codex';
if (runtime === 'copilot') program = 'Copilot';
if (runtime === 'antigravity') program = 'Antigravity';
if (runtime === 'cursor') program = 'Cursor';
if (runtime === 'windsurf') program = 'Windsurf';
if (runtime === 'augment') program = 'Augment';
if (runtime === 'trae') program = 'Trae';
if (runtime === 'cline') program = 'Cline';
if (runtime === 'qwen') program = 'Qwen Code';
if (runtime === 'hermes') program = 'Hermes Agent';
if (runtime === 'kimi') program = 'Kimi CLI';
let command = '/gsd-new-project';
if (runtime === 'opencode') command = '/gsd-new-project';
if (runtime === 'kilo') command = '/gsd-new-project';
if (runtime === 'gemini') command = '/gsd:new-project';
if (runtime === 'codex') command = '$gsd-new-project';
if (runtime === 'copilot') command = '/gsd-new-project';
if (runtime === 'antigravity') command = '/gsd-new-project';
if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)';
if (runtime === 'windsurf') command = '/gsd-new-project';
if (runtime === 'augment') command = '/gsd-new-project';
if (runtime === 'trae') command = '/gsd-new-project';
if (runtime === 'cline') command = '/gsd-new-project';
if (runtime === 'qwen') command = '/gsd-new-project';
if (runtime === 'hermes') command = '/gsd-new-project';
if (runtime === 'kimi') command = '/skill:gsd-new-project';
// program + command are now single-source lookups (ADR-1239 Phase B / #1679):
// program is the runtime display label; command is the per-host /gsd-new-project
// invocation syntax.
const program = getRuntimeLabel(runtime);
const command = getRuntimeNewProjectCommand(runtime);
// Claude Code global installs use the skills/ format (CC 2.1.88+).
// Restart is required for CC to pick up newly-installed skills, and the

View File

@@ -1,7 +1,7 @@
{
"id": "ai-integration",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "AI design contract",
"description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "antigravity",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Antigravity",
"description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; flat skill layout; tier-1 support.",
"tier": "core",
@@ -60,7 +60,14 @@
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" },
"dispatch": {
"namedDispatch": "undocumented",
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "assumption-delta",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Assumption-delta architecture checkpoint",
"description": "Rarely-firing advisory checkpoint that triggers when a phase makes something plural, optional, or chosen that used to be singular, required, or derived. Surfaces one identity-model question (promote the new general representation to primary, or add it alongside?) so a silent primary-key drift does not accumulate into a later user-facing bug. Non-blocking; fires only on a detected signal.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "audit",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Audit",
"description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "augment",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Augment Code",
"description": "Augment Code CLI — commands + nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -85,7 +85,14 @@
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "full", "backgroundDispatch": "undocumented" },
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "claude",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Claude Code",
"description": "Anthropic Claude Code — primary development runtime; tier-1 support with full hook surface and skills-based global install.",
"tier": "core",
@@ -66,7 +66,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 5, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 5,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": false
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "cline",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Cline",
"description": "Cline (VS Code extension) — global-only nested-skill layout; cline-rules hook surface (.clinerules); no hook events emitted; tier-2 support.",
"tier": "core",
@@ -43,7 +43,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "read-only",
"backgroundDispatch": false
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "code-review",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Code review",
"description": "Source-file code review and review-fix workflow support for completed execution work.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "codebuddy",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "CodeBuddy",
"description": "CodeBuddy (Tencent) — converted commands + skills artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -85,7 +85,14 @@
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": false
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "codex",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "OpenAI Codex CLI",
"description": "OpenAI Codex CLI — shell-var command style; per-agent sandbox tiers; config.toml + hooks.json hook surface; tier-1 support.",
"tier": "core",
@@ -53,7 +53,14 @@
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": true },
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": true
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "copilot",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "GitHub Copilot",
"description": "GitHub Copilot (VS Code) — markdown config format; copilot-inline hook surface; no hook events emitted; flat skill nesting (unconfirmed recursive loader); tier-2 support.",
"tier": "core",
@@ -53,7 +53,14 @@
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": false
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "cursor",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Cursor",
"description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.",
"tier": "core",
@@ -85,7 +85,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 2, "background": true, "subagentToolkit": "full", "backgroundDispatch": true },
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 2,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": true
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "drift",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Drift detection gates",
"description": "Drift detection gates for the planning loop. At execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md). At plan:pre: a non-blocking, warn-only codebase drift gate (gated on workflow.plan_drift_precheck) that flags a stale codebase map before planning, so plans are authored against a fresh STRUCTURE.md instead of discovering drift mid-execution.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "gap-analysis",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Post-planning gap analysis",
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
"tier": "standard",

View File

@@ -1,7 +1,7 @@
{
"id": "gemini",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Gemini CLI",
"description": "Google Gemini CLI — commands-only artifact layout (TOML); Gemini hook event dialect; settings-json hook surface; tier-2 support.",
"tier": "core",
@@ -57,7 +57,14 @@
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-toml",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": "undocumented",
"subagentToolkit": "undocumented",
"backgroundDispatch": false
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "graphify",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Knowledge graph",
"description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "hermes",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Hermes Agent",
"description": "Hermes Agent (NousResearch) — skills nest under skills/gsd/ category bucket; nested skill layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -53,7 +53,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-programmatic",
"dispatch": { "namedDispatch": false, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": false,
"nested": true,
"maxDepth": 1,
"background": true,
"subagentToolkit": "read-only",
"backgroundDispatch": false
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "intel",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Codebase intelligence",
"description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "kilo",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Kilo Code",
"description": "Kilo Code — XDG-based config dir; global skills at ~/.kilo/skills (separate from XDG config); flat command/ + skills artifact layout; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -75,7 +75,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": -1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": -1,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": false
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "kimi",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -56,7 +56,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": false
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "mempalace",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "MemPalace memory",
"description": "Cross-session, cross-project memory: deliberate recall before discuss/plan and verbatim capture + temporal-KG sync at phase boundaries, via the MemPalace MCP server and CLI.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "nyquist",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Nyquist validation",
"description": "Validation coverage audit that maps executed work back to tests and manual-only evidence.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "opencode",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "OpenCode",
"description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -70,7 +70,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": false, "subagentToolkit": "full", "backgroundDispatch": "undocumented" },
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": false,
"subagentToolkit": "full",
"backgroundDispatch": "undocumented"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "pattern-mapper",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Pattern mapping",
"description": "Optional codebase-pattern mapping before planning; owns the pattern mapper agent and workflow.pattern_mapper activation key.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "profile-pipeline",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Developer profiling pipeline",
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "qwen",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Qwen Code",
"description": "Qwen Code (Alibaba) — nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -57,7 +57,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": false
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "research",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Phase research",
"description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.",
"tier": "standard",

View File

@@ -1,7 +1,7 @@
{
"id": "schema-gate",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Schema push detection gate",
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "security",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Security enforcement",
"description": "Threat mitigation verification and ship-time security blocking for phases with security enforcement enabled.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "tdd",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Test-driven development",
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "trae",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Trae IDE",
"description": "Trae IDE — nested-skill artifact layout; no hook surface (profile-marker-only config); tier-2 support.",
"tier": "core",
@@ -68,7 +68,14 @@
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" },
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": "undocumented"
},
"modelMode": "passive",
"hookBus": "engine",
"stateIO": "filesystem",

View File

@@ -1,7 +1,7 @@
{
"id": "ui",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "UI design contracts",
"description": "UI-SPEC design contract + retrospective UI audit for frontend phases.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "windsurf",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Windsurf",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -61,7 +61,14 @@
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" },
"dispatch": {
"namedDispatch": "undocumented",
"nested": "undocumented",
"maxDepth": "undocumented",
"background": "undocumented",
"subagentToolkit": "undocumented",
"backgroundDispatch": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",

View File

@@ -315,6 +315,8 @@ For browser-backed UAT, use a configured browser MCP server. The current Open GS
**Coverage-aware UAT routing (#1602).** When a SUMMARY.md carries a `coverage:` frontmatter block, `verify-work` classifies each deliverable deterministically instead of prompting for every prose bullet: deliverables proven by passing tests are auto-passed (recorded with `source: automated`, no prompt) and only judgment-dependent deliverables are presented for human sign-off. SUMMARYs without a `coverage:` block fall back to the previous prose-based extraction unchanged. See the [`coverage:` block reference](#summary-coverage-block) below.
**Honest verifier — `insufficient_spec` abstention (#1154).** A `must_haves.truths` item carrying the `verification: backstop` marker (a *non-inferable* check the edge-probe surfaced at spec time) is graded specially: if the verifier cannot confirm it with **explicit evidence** (a passing wired held-out/property-based test, or a directly-observed behavior), it **abstains** — the item is reported `unverified — held-out test recommended` and the phase verdict becomes `human_needed` (with reason `insufficient_spec`, distinct from ordinary manual-UAT `human_needed`), **never a silent `passed`**. Autonomous runs complete with "N unverified non-inferable checks" rather than hard-halting; interactive runs route the item to the end-of-phase human checkpoint. Abstention is exogenous (driven by the `backstop` tag, never a self-judged "abstain if unsure") and an inferable truth is never abstained. Reliable on capable verifier tiers (`sonnet`+); the budget `haiku` tier degrades toward current behavior. See [Honest Verifier](../gsd-core/references/honest-verifier.md).
#### SUMMARY `coverage:` block
A SUMMARY.md may carry an optional `coverage:` frontmatter block — a list of per-deliverable entries that joins requirements → tests → verification status:
@@ -1639,6 +1641,28 @@ node gsd-tools.cjs state sync --verify # Dry-run: show changes without writin
---
### `state rebuild [--dry-run] [--verbose]`
Re-derive STATE.md body structure from canonical sources (frontmatter + `.planning/phases/` disk scan). Reconciles `## Current Position` prose with frontmatter, drops orphaned rows from the `**By Phase:**` table, clears template-placeholder field values, and de-duplicates `## Session Continuity Archive` blocks down to the 3 most-recent entries. Every mutation is recorded in a structured `## Rebuild Log` audit section appended to STATE.md (ADR-1817 §3).
Heavier and manual counterpart to the lightweight, auto-triggered `state sync`. The two compose non-overlappingly: `sync` patches three frontmatter fields; `rebuild` reconciles body structure. Per ADR-1817 §4, `rebuild` is idempotent — running it twice on a clean file produces no change.
| Flag | Description |
|------|-------------|
| `--dry-run` | Compute the rebuild and emit a structured preview, write nothing |
| `--verbose` | Tee the audit-log entries to stderr in addition to writing them to STATE.md |
**Prerequisites:** `.planning/STATE.md` exists
**Produces:** Reconciled `STATE.md` with a `## Rebuild Log` audit entry (only when drift was reconciled)
```bash
node gsd-tools.cjs state rebuild # Reconcile body structure
node gsd-tools.cjs state rebuild --dry-run # Preview the diff without writing
node gsd-tools.cjs state rebuild --verbose # Emit audit-log entries to stderr
```
---
### `state planned-phase`
Record state transition after plan-phase completes (Planned/Ready to execute).

View File

@@ -171,6 +171,10 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
| `brave_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Brave Search API availability. When unset, GSD checks for `BRAVE_API_KEY` env var or `~/.gsd/brave_api_key` file |
| `firecrawl` | boolean | `true`/`false` | auto-detected | Override auto-detection of Firecrawl API availability. When unset, GSD checks for `FIRECRAWL_API_KEY` env var or `~/.gsd/firecrawl_api_key` file |
| `exa_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Exa Search API availability. When unset, GSD checks for `EXA_API_KEY` env var or `~/.gsd/exa_api_key` file |
| `tavily_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Tavily Search API availability. When unset, GSD checks for `TAVILY_API_KEY` env var or `~/.gsd/tavily_api_key` file |
| `ref_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Ref search API availability. When unset, GSD checks for `REF_API_KEY` env var or `~/.gsd/ref_api_key` file |
| `perplexity` | boolean | `true`/`false` | auto-detected | Override auto-detection of Perplexity API availability. When unset, GSD checks for `PERPLEXITY_API_KEY` env var or `~/.gsd/perplexity_api_key` file |
| `jina` | boolean | `true`/`false` | `true` | Override auto-detection of Jina API availability. Jina is a terminal fallback in the docs waterfall and defaults to available (`true`); GSD checks for `JINA_API_KEY` env var or `~/.gsd/jina_api_key` file when an explicit override is needed |
| `search_gitignored` | boolean | `true`/`false` | `false` | Legacy top-level alias for `planning.search_gitignored`. Prefer the namespaced form; this alias is accepted for backward compatibility |
> **Note:** `granularity` was renamed from `depth` in v1.22.3. Existing configs are auto-migrated.
@@ -190,6 +194,10 @@ API key fields accept a string value (the key itself). They can also be set to t
| `brave_search` | string \| boolean \| null | `null` | Brave Search API key used for web research. Displayed as `****<last-4>` in all UI / `config-set` output; never echoed plaintext |
| `firecrawl` | string \| boolean \| null | `null` | Firecrawl API key for deep-crawl scraping. Masked in display |
| `exa_search` | string \| boolean \| null | `null` | Exa Search API key for semantic search. Masked in display |
| `tavily_search` | string \| boolean \| null | `null` | Tavily Search API key used in the web-discovery waterfall. Masked in display |
| `ref_search` | string \| boolean \| null | `null` | Ref search API key used in the docs-discovery waterfall. Masked in display |
| `perplexity` | string \| boolean \| null | `null` | Perplexity API key used in the web-discovery waterfall. Masked in display |
| `jina` | string \| boolean \| null | `null` | Jina API key (docs / scrape fallback). Masked in display |
**Masking convention (`gsd-core/bin/lib/secrets.cjs`):** keys 8+ characters render as `****<last-4>`; shorter keys render as `****`; `null`/empty renders as `(unset)`. Plaintext is written as-is to `.planning/config.json` — that file is the security boundary — but the CLI, confirmation tables, logs, and `AskUserQuestion` descriptions never display the plaintext. This applies to the `config-set` command output itself: `config-set brave_search <key>` returns a JSON payload with the value masked.

View File

@@ -3115,7 +3115,9 @@ When a requirement's prose matches **no** shape cue, the probe does not silently
The resolved edges populate a `## Edge Coverage` section in `SPEC.md`. Unresolved *applicable* edges trigger a soft gate (Resolve / Write-anyway-flagged / Keep-probing) rather than a hard block. Under `--auto`, the probe **never auto-dismisses** — it auto-covers where a defensible criterion exists, otherwise auto-backstops, and logs `[auto] edge coverage: C covered, B backstop, U unresolved`. The one exception is an `unclassified` candidate: `--auto` leaves it **`unresolved`** (surfaced as a flagged assumption), never auto-`backstop` — a missing shape is not evidence an edge exists, so minting a held-out edge obligation would be a false claim.
The load-bearing wire is the `plan-phase` lift: `covered` and `backstop` edges become `must_haves.truths` the verifier can check, so the section is not merely documentation.
The load-bearing wire is the `plan-phase` lift: `covered` and `backstop` edges become `must_haves.truths` the verifier can check, so the section is not merely documentation. A `backstop` edge is lifted as a **structured non-inferable marker** (`{ statement, verification: backstop }`, a flat scalar — not a prose note), which the **honest verifier** then consumes (see below) — closing the loop the edge-probe opened.
**Honest verifier — abstention on non-inferable checks (#1154).** A non-inferable (`backstop`) truth is one whose correct behavior is not derivable from the spec alone, so the verifier cannot self-detect the gap and would confidently false-pass it (~100% of the time). At verify time, a `backstop` truth the verifier cannot confirm with **explicit evidence** (a passing wired held-out/property-based test, or a directly-observed behavior) **abstains** → `human_needed` with reason `insufficient_spec` (reported as `unverified — held-out test recommended`), **never a silent `passed`**. This is the verify-time, truth-axis mirror of the prohibition judgment-tier disposition (ADR-550 D4): exogenous (driven by the `backstop` tag, never a self-judged "abstain if unsure"), routing-not-diagnosis (the held-out test carries the omitted rule), and capable-tier dependent (reliable on `sonnet`+; the budget `haiku` tier degrades toward current behavior). An inferable truth is never abstained (the over-abstention guard). Reference: [Honest Verifier](../gsd-core/references/honest-verifier.md).
**Requirements:**
- REQ-EDGE-01: The edge pass MUST run after the ambiguity gate and emit a `## Edge Coverage` SPEC section.
@@ -3125,6 +3127,8 @@ The load-bearing wire is the `plan-phase` lift: `covered` and `backstop` edges b
- REQ-EDGE-05: `--auto` MUST never auto-dismiss — auto-cover or auto-backstop only.
- REQ-EDGE-06: `plan-phase` MUST lift `covered` criteria and `backstop` notes into `must_haves.truths`.
- REQ-EDGE-07: A requirement whose prose matches no shape cue MUST surface an `unclassified — review manually` candidate (never silently dropped); `--auto` MUST leave it `unresolved`, never auto-`backstop`.
- REQ-EDGE-08: `plan-phase` MUST lift a `backstop` edge into `must_haves.truths` as a structured flat-scalar marker (`{ statement, verification: backstop }`), never a prose parenthetical.
- REQ-HONEST-01: At verify time a `backstop` truth that cannot be confirmed with explicit evidence MUST abstain → `human_needed` (reason `insufficient_spec`), never `passed`; an inferable truth MUST never be abstained (over-abstention guard); abstention MUST be exogenous (driven by the `backstop` tag, not self-judgment).
**Reference:** [Edge Probe](../gsd-core/references/edge-probe.md)

View File

@@ -222,6 +222,7 @@
"gates.md",
"git-integration.md",
"git-planning-commit.md",
"honest-verifier.md",
"ios-scaffold.md",
"loop-hook-dispatch.md",
"mandatory-initial-read.md",
@@ -327,6 +328,7 @@
"eval-command-router.cjs",
"eval.cjs",
"embedding-adapter.cjs",
"external-descriptor-trust.cjs",
"fallow-runner.cjs",
"federated-config.cjs",
"frontmatter.cjs",
@@ -335,6 +337,7 @@
"graphify-command-router.cjs",
"graphify.cjs",
"gsd2-import.cjs",
"hook-bus.cjs",
"host-integration.cjs",
"init-command-router.cjs",
"init.cjs",
@@ -351,7 +354,9 @@
"loop-host-contract.cjs",
"loop-resolver.cjs",
"markdown-sectionizer.cjs",
"mcp-server.cjs",
"milestone.cjs",
"model-adapter.cjs",
"model-catalog.cjs",
"model-profiles.cjs",
"model-resolver.cjs",
@@ -397,6 +402,7 @@
"stale-bake-guard.cjs",
"state-command-router.cjs",
"state-document.cjs",
"state-io.cjs",
"state-transition.cjs",
"state.cjs",
"surface.cjs",

View File

@@ -308,6 +308,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum
| `domain-probes.md` | Domain-specific probing questions for discuss-phase. |
| `edge-probe.md` | Spec-phase edge-completeness probe — 8-category edge taxonomy, shape classification, and the `requirements → checks → verifier` resolution model (Step 5.5). |
| `prohibition-probe.md` | Spec-phase prohibition-completeness probe — the two-stage adversarial-recall → precision protocol that surfaces the unwritten *must-NOT* constraints (values/safety/ethics), with status×verification (`test`/`judgment`) tiering and canon-referral breadcrumbs (Step 5.6); second adapter of the `probe-core` resolution model. |
| `honest-verifier.md` | Verify-time abstention on non-inferable (`backstop`) truths — the truth-axis mirror of the prohibition judgment-tier disposition (ADR-550 D4): a `backstop` truth the verifier can't confirm with explicit evidence abstains → `human_needed` (reason `insufficient_spec`), never a silent pass (#1154). |
| `gate-prompts.md` | Gate/checkpoint prompt templates. |
| `loop-hook-dispatch.md` | Generic dispatch contract for consuming `gsd_run loop render-hooks <point> --raw` output in any host-loop workflow — envelope shape, per-kind dispatch rules (contribution/step/gate), and liveness banner. |
| `scout-codebase.md` | Phase-type→codebase-map selection table for discuss-phase scout step (extracted via the discuss-phase/modes progressive-disclosure split, #717). |

View File

@@ -0,0 +1,279 @@
# ADR-1817: STATE.md rebuild — derivability contract (capstone transition)
- **Status:** Accepted (Phase 0 — ADR + CONTEXT.md update; lands ahead of Phases 1–2)
- **Date:** 2026-06-29
- **Issue:** [#1817](https://github.com/open-gsd/gsd-core/issues/1817) — epic
- **Builds on:** [ADR-1769](1769-state-md-transition-module.md) (STATE.md Transition Module). Adds the 11th transition (`rebuild`) on top of ADR-1769's 10 lifecycle/maintenance intents.
- **Supersedes:** nothing. Extends ADR-1769's transition set; does not revisit its design.
## Context
ADR-1769 landed the STATE.md Transition Module with 10 intent-based transitions
(`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`,
`milestoneComplete`, `patch`, `sync`, `prune`, `update`) and a field-classification
table that killed the per-call-site preservation-policy bug cluster (#1760, #1761,
#1743, #1695, #1264, #1255, #1257, #3242). Each transition touches **individual
fields**.
A second bug class survived ADR-1769: **body-structure drift** that no current
command can reconcile. `syncCore` (the lightest-weight transition) only patches
three frontmatter fields — `Total Plans in Phase`, `Progress`, `Last Activity` —
and intentionally does not re-derive body structure. `buildStateFrontmatter`
re-derives all frontmatter from body + disk scan but does not touch the body
itself. The result: **the body can diverge from ground truth indefinitely while
`gsd-tools state sync` reports `synced: true`.**
Observed drift signatures (full list in epic #1817):
- `## Current Position` prose fields (Phase, Status, Current Plan) contradict
frontmatter after a milestone switch or prune.
- `## By-Phase Progress` table has orphaned rows for phases from a prior
milestone or rows with zero-padded phase IDs that were renamed.
- Template-placeholder field values (`[phase name]`, `[date]`) left in place
when an AI agent wrote partial state.
- Duplicate `## Session Continuity Archive` blocks from repeated
`state record-session` calls on a corrupt file.
- `stopped_at` in frontmatter sourced from the wrong section (an archive block
rather than the current `## Session` block) — bug #2444's guard only applies
in `buildStateFrontmatter`, not to the body itself.
- `progress.total_phases` in frontmatter correct, but `Phase: [N] of [M]` prose
still shows the old milestone's count.
Three open issues sit in this defect class: **#1776** (`cmdStatePrune` phase
fallback matches any `| Phase | N |` table cell, not `## Current Position`
prose → requires scoped body extraction), **#1761** (`state sync` writes wrong
progress when ROADMAP lacks versioned milestone headings → a rebuild would
re-derive from disk + ROADMAP together), **#1591** (`phase.complete` mis-parses
`<details>`-wrapped roadmaps and garbles counters → a rebuild can reconcile
from canonical disk sources rather than parsing ROADMAP mid-transition).
The deeper shape: **every body-level drift bug today requires a per-bug regex
fix.** Ten-plus closed issues (#1658, #1659, #1668, #1446, #1230, #948, #549,
#500, #363, #316) each added a narrow guard that didn't prevent the next
variant. A `rebuild` transition that re-derives the **canonical body sections**
from ROADMAP + phase dirs + session history would resolve the entire class
without per-bug patches.
## Decision
Add `rebuild` as the **11th intent** in `transitionCore` (ADR-1769 §6 "Core
scope: writes only"). Six design decisions, resolved via `/grilling`:
### 1. The `rebuild` intent is a maintenance transition, same tier as the other 10
`rebuild` is a STATE.md Transition Module method, dispatched from
`transitionCore`'s switch alongside `sync`, `prune`, `update`, etc. Pure core
`(content, intent, deps) → newContent`, same shape as ADR-1769 §3.
**Capability tier (ADR-857 analog):** `rebuild` is **core substrate**, not a
Feature Capability. It is non-toggleable, lives inside the transition module,
and is not subject to ADR-857 capability consent/overlay. This mirrors ADR-550
§83's "verifier↔predicate contract is core/non-toggleable" rule for the
verification seam: the derivability contract documented here is the state-seam
equivalent.
*Rejected:* (B) Implement `rebuild` as a Feature Capability that users opt into
— rejected because the bug class is core STATE.md correctness, not optional
behavior. (C) Keep `rebuild` outside the Transition Module (a sibling
utility) — rejected: it must own the same lock→read→apply→preserve→write
transaction ADR-1769 §1 specified for the other 10 transitions; a sibling
utility would re-encapsulate that machinery and drift.
### 2. Section taxonomy: derived vs preserved
Each STATE.md body section is classified as **re-derivable** or **preserved**.
`rebuild` consults the taxonomy; it never re-derives a preserved section and
never preserves a re-derivable one verbatim when the canonical source
disagrees.
| Section | Class | Source of truth | Rebuild behavior |
|---|---|---|---|
| `## Current Position` prose | re-derivable | Frontmatter (which `buildStateFrontmatter` already derives correctly from disk + ROADMAP) | Re-derive each prose field from the corresponding frontmatter field; replace verbatim. |
| `## By-Phase Progress` table | re-derivable | Phase dirs on disk (same source as `buildStateFrontmatter`'s disk scan) | Re-derive the entire table from disk; drop orphaned rows. |
| `## Session` block | preserved | Human-curated current-session data | Preserve verbatim. (Only the *current* `## Session` block; archived sessions are de-duplicated per §4.) |
| `## Decisions` | preserved | Human-curated decision log | Preserve verbatim. Staleness is `pruneCore`'s concern, not rebuild's. |
| `## Session Continuity Archive` | preserved, de-duplicated | Prior session snapshots | Keep the most-recent N (configurable; default 3); drop duplicates; preserve kept entries verbatim. |
| `## Rebuild Log` | appended (new section) | The rebuild transition itself | Append one entry per rebuild that mutated the file; never re-derive or edit prior entries. |
| Any other `## …` section | preserved (unknown) | Human-curated | Preserve verbatim. Rebuild does not recognize or rewrite sections outside the taxonomy. |
**Section ordering is invariant.** Rebuild rewrites the *content* of
re-derivable sections in place; it does not reorder sections, insert new
sections (other than `## Rebuild Log` if absent), or remove sections.
*Principle:* derive what is derivable, preserve what is curated, log what is
dropped. (Postel's Law applied to a state file: be liberal in what you accept
— any drifted input — and conservative in what you send — canonical form for
derived, verbatim for preserved.)
*Rejected:* (α) Treat all sections as re-derivable — rejected: destroys
human-curated content (session notes, decisions). (β) Treat all sections as
preserved — rejected: this is the status quo; the drift class survives.
### 3. Orphaned data: log + drop (audit trail mandatory)
When `rebuild` encounters canonical-source-disagreement that requires dropping
data, it MUST append a structured entry to `## Rebuild Log` recording:
- `timestamp` (ISO-8601, from the injected `clock`)
- `kind` — one of `orphaned-row`, `placeholder-removed`, `archive-deduplicated`,
`wrong-section-source`, `milestone-count-stale`, or `section-rewritten`
- `section` — which body section was mutated
- `before` / `after` — the dropped/changed content (truncated to 512 chars per
entry to bound log growth)
- `reason` — short structured string explaining the canonical source that won
`## Rebuild Log` is itself **preserved** (per §2). Rebuild never rewrites or
truncates prior log entries; it only appends. A separate prune step (out of
scope here) governs log retention.
**Why log everything:** dropping user-adjacent data without a trace is hostile
even when the drop is correct. The log gives the user an undo path (manual
re-add) and gives the maintainer a debugging signal when rebuild drops
something it shouldn't have. (Hyrum's Law mitigation: the drop is observable,
the audit trail is the contract.)
*Rejected:* silent drop — rejected: violates the ADR-1411 resolution-provenance
principle that mutation decisions report what they did, not fall open silently.
### 4. Idempotency is a hard guarantee
`rebuild` is **idempotent**: invoking it twice in succession on the same file
produces no change on the second invocation. This is testable and tested.
The idempotency contract has two parts:
1. **Body content idempotency:** re-running rebuild on a file rebuild just
canonicalized produces byte-identical `## Current Position` and
`## By-Phase Progress` sections.
2. **Rebuild Log idempotency:** a rebuild that mutates nothing appends no log
entry. (This is what makes the second-invocation case truly byte-identical
— without it, the second run would always append a no-op log entry and
violate idempotency.)
The log-appends-only-on-mutation rule is the load-bearing constraint. If
rebuild wrote a log entry unconditionally on every invocation, idempotency
would break.
### 5. Interaction with `sync` — non-overlapping scopes
`sync` and `rebuild` compose; they do not compete.
| Transition | Scope | Trigger | Latency |
|---|---|---|---|
| `sync` | 3 frontmatter fields (`Total Plans in Phase`, `Progress`, `Last Activity`) | Auto-triggered on every state transition | Lightweight, runs on every transition |
| `rebuild` | Body structure (`## Current Position`, `## By-Phase Progress`, archive dedup) | Manual (`gsd-tools state rebuild`) | Heavier; reads disk + ROADMAP; explicit user invocation |
Running `sync` after `rebuild` is safe: `sync`'s 3 fields are a strict subset
of what `rebuild` reconciled (in canonical form), so sync's derivation will
produce the same values rebuild just wrote. Running `rebuild` after `sync` is
also safe: rebuild re-derives body from canonical sources; sync's just-written
frontmatter is one of those sources.
`sync` stays as the auto-triggered lightweight path; `rebuild` is the
user-invoked heavy reconciliation. Neither subsumes the other.
### 6. Interaction with `auto_prune_state` — orthogonal concerns
`rebuild` does NOT prune. Pruning (removing data that is no longer relevant,
e.g. decisions older than N sessions, prior-milestone state) is a separate
concern governed by `auto_prune_state` and `pruneCore`.
The distinction:
- **Rebuild reconciles** body with current canonical sources. A `## By-Phase
Progress` row for a phase that no longer exists on disk is dropped because
it is *canonical-mismatched*, not because it is *old*.
- **Prune removes** data based on age/staleness policy. A `## Decisions`
entry from 6 months ago stays under rebuild (preserved) but may be removed
by prune based on retention policy.
The two compose: rebuild first (reconcile with canonical sources), then prune
(remove per policy). Rebuild never makes pruning decisions; prune never
re-derives structure.
## Consequences
**Positive:**
- The body-structure drift bug class is killed structurally. #1776, #1761,
and #1591 each become either directly fixable by `rebuild` or indirectly
addressable (the rebuild provides the scoped body extraction those bugs
need).
- The derivability contract is a new correctness invariant: STATE.md body
is **derivable from canonical sources at any time**, not just incrementally
updatable. This is the capstone property ADR-1769's per-field transitions
couldn't deliver alone.
- The audit log gives the maintainer a debugging signal when STATE.md editing
(manual or AI-driven) produces drift that rebuild later reconciles.
- Future drift classes (anything not in the §2 taxonomy today) can be added
by extending the taxonomy + a new `kind` in the log enum, without
re-touching the transition core's dispatch shape (Gall's Law: extend, don't
rewrite).
**Negative:**
- A new body section (`## Rebuild Log`) is added to STATE.md. Older GSD
versions reading the file ignore the section (preserved verbatim by
`readModifyWriteStateMd`'s post-sync block — the section name is not in
the field-classification table, so it falls through as "unknown, preserved").
- The derivability contract is a new shared artifact: any future STATE.md
body section must declare its taxonomy class. Adding a new re-derivable
section is a non-trivial change (rebuild must learn the derivation rule);
adding a new preserved section is mechanical.
- The first invocation of `rebuild` on a long-lived project will produce a
substantial audit log entry (the project's accumulated drift is reconciled
in one pass). This is honest — the drift existed; rebuild surfaces it —
but users may be surprised by the log size on first run. Mitigation:
`--dry-run` flag (Phase 2) previews the diff before writing.
**Neutral:**
- `rebuildCore` is a pure function over `(content, intent, deps)`, callable
inside any orchestration shape (single-file write, multi-file transaction,
dry-run preview). Same property that let ADR-1769 §3 run `completePhase`
inside `writePlanningFileSet`.
- The existing 10 transitions are unchanged. `transitionCore`'s switch grows
from 10 cases to 11; the missing-case-compile-time-error guarantee
(ADR-1769 §1) extends to the new case.
## Alternatives considered
1. **Fix each body-level bug individually (status quo).** Rejected: already
done for 10+ closed issues. Each fix is a narrow regex guard that doesn't
prevent the next variant. Does not scale; the open issues (#1776, #1761,
#1591) are evidence.
2. **`state sync` expansion — extend `syncCore` to cover body structure.**
Rejected: `sync` is intentionally lightweight and auto-triggers on every
transition. Making it re-derive body structure would make every state
transition pay the disk-scan + ROADMAP-read cost, and would couple
auto-triggered behavior to a heavier and riskier code path. The
manual/auto split (§5) is the right factoring.
3. **Regenerate STATE.md from scratch (nuke-and-rebuild).** Rejected: loses
curated human content (session notes, decisions, archives). The
derivability contract is *selective* — derived sections re-derive,
preserved sections survive — which is exactly what a nuke-and-rebuild
cannot do.
4. **A standalone `state-doctor` workflow outside the transition module.** Rejected:
same drift-from-canonical-shape risk that motivated ADR-1769's
consolidation. A workflow that bypasses the transition module re-imports
the lock/scan/preservation machinery and re-creates the bug class.
5. **Defer until ADR-1769's amendments (#1796) finish independently.**
Rejected: ADR-1769 is closed (Phase 7 closeout + #1796 amendment landed).
There is no consumer-driven sequencing constraint; the rebuild transition
composes cleanly with the existing 10.
## Phases
This epic (#1817) is implemented in three phases, each its own PR. Phase 0
closes this issue (the epic); Phases 1 and 2 close their own sub-issues.
| Phase | Scope | Closes issue | Bug coverage |
|---|---|---|---|
| 0 | ADR + CONTEXT.md update (derivability contract, preserved-vs-derived taxonomy, idempotency, sync/prune interaction) | #1817 | — |
| 1 | `rebuildCore` body + `rebuild` intent dispatch case + drift-class unit tests | #1827 | surfaces the class; #1776, #1761, #1591 become directly addressable |
| 2 | `cmdStateRebuild` CLI + `--dry-run` / `--verbose` + integration tests + `docs/commands/state.md` + changeset | #1826 | end-to-end reconciliation available to users |
Per-transition discipline (inherited from ADR-1769 §7): characterization tests
first (capture the drift signatures we want to reconcile), then implement
`rebuildCore`, then verify existing `pruneCore` / `syncCore` tests still pass,
then add idempotency tests.

View File

@@ -143,6 +143,22 @@ This ratifies the **deterministic SOURCE** for the test-tier `CheckDescriptor` t
Net effect on D3: the prohibition-item shape is extended with three optional, backward-compatible flat-scalar keys that give the test-tier locate a deterministic spec-phase source; the contract's CI-testable surface (D5) gains the projection round-trip parity (CHK-03), the fail-closed guard (CHK-06), and the byte-stable backward-compat fixture (CHK-07). The decision also lives in `src/probe-core.cts` / `src/prohibition-enforcement.cts` comments, the `verify-phase.md` / `spec-phase.md` prose, and the #1278 changeset.
## Addendum (2026-06-25, #1154) — honest verifier: the truth-axis disposition mirror of D4
This records the **truth-axis half of Decision 4** that the original ADR deliberately scoped out. D3 left `truths` untouched (no `polarity` field — that is a prohibition concern), and the Lineage note parked the N17 abstention experiment as the verify-time half of the **prohibition** judgment-tier only. D7a, however, already gives the **edge** axis an orthogonal `verification` tier (`explicit | backstop`), and `plan-phase` already lifts a `backstop` edge into `must_haves.truths`. Until now that tier was flattened to a prose parenthetical at the projection, so the verifier had nothing structured to branch on and graded a `backstop` truth `passed` like any inferable one — confidently false-passing a non-inferable check ~100% of the time (the exact "verifier reach = spec reach" failure ADR-857 names). This addendum closes that gap by giving the `backstop` **truth** tier the same abstain-and-flag disposition D4 gave the prohibition `judgment` tier — **opposite polarity (must-HAVE under-specified vs must-NOT irreducible), same never-silent-pass machinery.** It cross-references **ADR-857** (the verifier↔predicate contract this rides is core, non-toggleable substrate, graded exogenously — `:65`); this completes the already-endorsed edge branch of that rail and adds no parallel mechanism.
1. **The D3 truth-item shape gains an OPTIONAL flat-scalar `verification` marker.** A `must_haves.truths` item is normally a plain string (an inferable truth — today's shape, unchanged). A **non-inferable** truth MAY instead be an object item carrying `statement` + a flat scalar `verification: backstop`. The marker is additive and default-absent: a string truth, or an object with no marker, behaves byte-identically to today (Hyrum's Law backward-compat). This extends the **Decision 3 truth shape** the same way #1278 extended the prohibition shape — it does **not** add a `polarity` field (D3's "truths untouched" holds); `verification` is D7a's pre-existing orthogonal axis, now carried through to the truth projection.
2. **Flat scalars — NOT a nested object (load-bearing, #1278 precedent).** The marker is a flat `verification:` continuation key on the truth item, never a nested object — the shared flat `parseMustHavesBlock` round-trips it with **no parser change** (the round-trip parity test is the proof; the untouched frontmatter suite confirms `truths`/`artifacts`/`key_links`/`prohibitions` readers stay regression-free).
3. **Deterministic disposition + projection.** `projectTruths` (`src/probe-core.cts`) emits the flat-scalar marker ONLY for a `backstop` truth and collapses every inferable truth to a bare string (conservative serializer). `dispositionForUnverifiableTruth(truth, { evidence })` is the pure, fail-closed verdict: a `backstop` truth with no **explicit evidence** (a passing wired held-out/property-based test, or a directly-observed behavior) → `{ status: 'unverified', flagged: true, reason: 'insufficient_spec' }`, **never green**; with evidence → green; any non-`backstop` truth → green (the over-abstention guard). No LLM judgment is tested (D5) — the helper owns routing once evidence-existence is known; the LLM verifier's only job is to decide whether explicit evidence exists.
4. **`insufficient_spec` feeds the EXISTING `human_needed` outcome — no new verifier status (maintainer Decision 1).** Abstention reuses the locked 3-value `VERIFIER_STATUSES` (`['passed','gaps_found','human_needed']`, `src/verification.cts`) with **zero change** and no new downstream routing in `ship`/`execute-phase`. The abstain cause rides as a **distinguishable report reason** (`human_needed` + `reason: insufficient_spec`) so it is never conflated with an ordinary manual-UAT `human_needed` — asserted in a test (review condition-1 caveat). *Interactive:* the item routes to the end-of-phase human checkpoint. *Autonomous (AFK):* a prominent `unverified — held-out test recommended` flag; completion reads "complete with N unverified non-inferable checks" — never a silent pass, never a hard halt (the D4 guarantee, now on the truth axis).
5. **Two measured properties define the design (maintainer Decision 2; caveats to record).** *Exogenous, not endogenous:* abstention is triggered by the external `backstop` tag, never a self-judged "abstain if unsure" — endogenous abstention was measured near-useless on true blind spots (100% → 67% vs exogenous 100% → 17%; N17). *Routing, not diagnosis:* the verdict does not name the omitted rule (the held-out test carries it). **Evidence honesty:** N17 is n=27, 1 rep — **direction-finding, not powered**; the effect is large and monotone but real-world precision depends on the edge-probe's *true* `backstop` recall/precision (the experiment modeled a perfect tagger), which is why the over-abstention guard and the capable-tier requirement are load-bearing acceptance criteria. **Model-tier coupling:** abstention is reliable on the default `gsd-verifier` tier (`sonnet`+); the budget tier (`haiku`) heeds the tag only inconsistently and degrades toward current behavior — captured as a documented cost (and a test) so a tier regression is caught, not discovered in production.
Net effect: the truth-axis `backstop` tier gains the verify-time disposition D4 gave the prohibition judgment tier; the contract's CI-testable surface (D5) gains the truth-axis projection round-trip parity and the abstain-on-unconfirmed-backstop regression. The decision also lives in `src/probe-core.cts` comments, `gsd-core/references/honest-verifier.md`, the `plan-phase.md` / `verify-phase.md` / `agents/gsd-verifier.md` prose, and the #1154 changeset.
## Addendum (2026-06-22) — Alternatives considered (recall / representation / packaging side)
This consolidates the spec-phase-side rejected and deferred alternatives for the probe family,

View File

@@ -62,6 +62,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop
| [1508-runtime-artifact-conversion-module.md](1508-runtime-artifact-conversion-module.md) | Runtime Artifact Conversion Module owns per-runtime content rewriting | Accepted |
| [1593-skill-mapping-converter-methodology.md](1593-skill-mapping-converter-methodology.md) | Skill mapping & converter methodology across runtimes | Accepted |
| [1769-state-md-transition-module.md](1769-state-md-transition-module.md) | STATE.md Transition Module — intent-based transitions over scattered RMW callbacks | Proposed |
| [1817-state-md-rebuild-derivability-contract.md](1817-state-md-rebuild-derivability-contract.md) | STATE.md rebuild — derivability contract (capstone 11th transition) | Accepted |
## Seam map

View File

@@ -0,0 +1,75 @@
# How to connect a host to the GSD companion MCP server
This guide shows you how to make a MCP-capable host (Claude Code, Codex,
OpenCode, VS Code, Gemini CLI, Cursor, Cline, Hermes) drive GSD — run GSD
commands and read/write `.planning/` state — through the companion MCP server,
with no bespoke plugin.
Once connected, three tools appear in the host alongside its others:
`gsd_invoke_command`, `gsd_read_state`, `gsd_write_state`. (For the tool
contracts, see the reference section below; for *why* this server exists and
its trust model, see [ADR-1239](../adr/1239-gsd-embeddable-orchestration-engine.md)
and the [capability trust model](../explanation/capability-trust-model.md).)
## 1. Add the server to your host's MCP config
The entry shape is the same everywhere; only the config file and key differ by
host.
```jsonc
{
"gsd": {
"command": "npx",
"args": ["-y", "@opengsd/gsd-core", "gsd-mcp-server"],
"cwd": "/abs/path/to/your/project"
}
}
```
- **Claude Code / Codex / OpenCode / Cursor / Cline / Hermes** — under the
host's `mcpServers` object (project or user config).
- **VS Code** — in the workspace MCP servers list.
- **Gemini CLI** — under its `mcpServers` block.
Set `cwd` to the project whose `.planning/` you want GSD to manage — the server
resolves state paths against it.
## 2. Restart the host
On startup the host performs the MCP `initialize` handshake, lists tools, and
the three GSD tools become callable.
## 3. Verify
Ask the host to read an existing planning file:
```jsonc
{ "name": "gsd_read_state", "arguments": { "path": "/abs/path/to/your/project/.planning/STATE.md" } }
```
It returns the file's contents. `gsd_invoke_command` takes
`{family, subcommand, args}` and returns the command-routing hub's structured
result (the same shape `gsd-tools` produces).
## If something does not work
- **`command not found: gsd-mcp-server`** — invoke via `npx` as shown above, or
install the package globally first (`npm i -g @opengsd/gsd-core`).
- **`gsd_read_state` fails with ENOENT** — the path is resolved literally; pass
an absolute path under the project's `.planning/`.
- **The host lists no GSD tools** — confirm the server starts in isolation:
`npx @opengsd/gsd-core gsd-mcp-server` then send an `initialize` request on
stdin; it writes a `protocolVersion` response and exits on EOF.
- **You manage multiple projects** — register one `gsd` entry per project with a
distinct name and `cwd`; the server is stateless across projects.
## Reference — the three tools
| Tool | Arguments | Returns |
|------|-----------|---------|
| `gsd_invoke_command` | `{family: string, subcommand: string, args?: unknown[]}` | the command-routing hub result (`{ok, …}`) as JSON text |
| `gsd_read_state` | `{path: string}` | the file contents as text |
| `gsd_write_state` | `{path: string, content: string}` | `{ok: true, path}` as JSON text |
Errors from a tool are returned as MCP tool errors (`isError: true`), not as
JSON-RPC protocol errors — the host surfaces them in its normal tool-failure UX.

View File

@@ -204,6 +204,11 @@ export default tseslint.config(
'gsd-core/bin/lib/embedding-adapter.cjs',
'gsd-core/bin/lib/adapter-declarative.cjs',
'gsd-core/bin/lib/adapter-imperative.cjs',
'gsd-core/bin/lib/model-adapter.cjs',
'gsd-core/bin/lib/hook-bus.cjs',
'gsd-core/bin/lib/state-io.cjs',
'gsd-core/bin/lib/external-descriptor-trust.cjs',
'gsd-core/bin/lib/mcp-server.cjs',
],
},
@@ -251,7 +256,7 @@ export default tseslint.config(
// bin/install.js is ~12k lines of generated code; the ADR's mandate is the
// portability defect surface, not a broader generated-code style sweep.
{
files: ['bin/install.js', 'scripts/build-hooks.js'],
files: ['bin/install.js', 'bin/gsd-mcp-server.js', 'scripts/build-hooks.js'],
plugins: {
local: localPlugin,
},

View File

@@ -1,6 +1,6 @@
{
"name": "gsd-core",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.",
"contextFileName": "GEMINI.md"
}

View File

@@ -10,7 +10,7 @@ const capabilities = {
"ai-integration": {
"id": "ai-integration",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "AI design contract",
"description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.",
"tier": "full",
@@ -63,7 +63,7 @@ const capabilities = {
"antigravity": {
"id": "antigravity",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Antigravity",
"description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; flat skill layout; tier-1 support.",
"tier": "core",
@@ -141,7 +141,7 @@ const capabilities = {
"assumption-delta": {
"id": "assumption-delta",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Assumption-delta architecture checkpoint",
"description": "Rarely-firing advisory checkpoint that triggers when a phase makes something plural, optional, or chosen that used to be singular, required, or derived. Surfaces one identity-model question (promote the new general representation to primary, or add it alongside?) so a silent primary-key drift does not accumulate into a later user-facing bug. Non-blocking; fires only on a detected signal.",
"tier": "full",
@@ -187,7 +187,7 @@ const capabilities = {
"audit": {
"id": "audit",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Audit",
"description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).",
"tier": "full",
@@ -224,7 +224,7 @@ const capabilities = {
"augment": {
"id": "augment",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Augment Code",
"description": "Augment Code CLI — commands + nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -327,7 +327,7 @@ const capabilities = {
"claude": {
"id": "claude",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Claude Code",
"description": "Anthropic Claude Code — primary development runtime; tier-1 support with full hook surface and skills-based global install.",
"tier": "core",
@@ -411,7 +411,7 @@ const capabilities = {
"cline": {
"id": "cline",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Cline",
"description": "Cline (VS Code extension) — global-only nested-skill layout; cline-rules hook surface (.clinerules); no hook events emitted; tier-2 support.",
"tier": "core",
@@ -472,7 +472,7 @@ const capabilities = {
"code-review": {
"id": "code-review",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Code review",
"description": "Source-file code review and review-fix workflow support for completed execution work.",
"tier": "full",
@@ -533,7 +533,7 @@ const capabilities = {
"codebuddy": {
"id": "codebuddy",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "CodeBuddy",
"description": "CodeBuddy (Tencent) — converted commands + skills artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -636,7 +636,7 @@ const capabilities = {
"codex": {
"id": "codex",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "OpenAI Codex CLI",
"description": "OpenAI Codex CLI — shell-var command style; per-agent sandbox tiers; config.toml + hooks.json hook surface; tier-1 support.",
"tier": "core",
@@ -707,7 +707,7 @@ const capabilities = {
"copilot": {
"id": "copilot",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "GitHub Copilot",
"description": "GitHub Copilot (VS Code) — markdown config format; copilot-inline hook surface; no hook events emitted; flat skill nesting (unconfirmed recursive loader); tier-2 support.",
"tier": "core",
@@ -778,7 +778,7 @@ const capabilities = {
"cursor": {
"id": "cursor",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Cursor",
"description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.",
"tier": "core",
@@ -881,7 +881,7 @@ const capabilities = {
"drift": {
"id": "drift",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Drift detection gates",
"description": "Drift detection gates for the planning loop. At execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md). At plan:pre: a non-blocking, warn-only codebase drift gate (gated on workflow.plan_drift_precheck) that flags a stale codebase map before planning, so plans are authored against a fresh STRUCTURE.md instead of discovering drift mid-execution.",
"tier": "full",
@@ -959,7 +959,7 @@ const capabilities = {
"gap-analysis": {
"id": "gap-analysis",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Post-planning gap analysis",
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
"tier": "standard",
@@ -1000,7 +1000,7 @@ const capabilities = {
"gemini": {
"id": "gemini",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Gemini CLI",
"description": "Google Gemini CLI — commands-only artifact layout (TOML); Gemini hook event dialect; settings-json hook surface; tier-2 support.",
"tier": "core",
@@ -1075,7 +1075,7 @@ const capabilities = {
"graphify": {
"id": "graphify",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Knowledge graph",
"description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.",
"tier": "full",
@@ -1116,7 +1116,7 @@ const capabilities = {
"hermes": {
"id": "hermes",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Hermes Agent",
"description": "Hermes Agent (NousResearch) — skills nest under skills/gsd/ category bucket; nested skill layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -1187,7 +1187,7 @@ const capabilities = {
"intel": {
"id": "intel",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Codebase intelligence",
"description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.",
"tier": "full",
@@ -1239,7 +1239,7 @@ const capabilities = {
"kilo": {
"id": "kilo",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Kilo Code",
"description": "Kilo Code — XDG-based config dir; global skills at ~/.kilo/skills (separate from XDG config); flat command/ + skills artifact layout; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -1332,7 +1332,7 @@ const capabilities = {
"kimi": {
"id": "kimi",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -1406,7 +1406,7 @@ const capabilities = {
"mempalace": {
"id": "mempalace",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "MemPalace memory",
"description": "Cross-session, cross-project memory: deliberate recall before discuss/plan and verbatim capture + temporal-KG sync at phase boundaries, via the MemPalace MCP server and CLI.",
"tier": "full",
@@ -1580,7 +1580,7 @@ const capabilities = {
"nyquist": {
"id": "nyquist",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Nyquist validation",
"description": "Validation coverage audit that maps executed work back to tests and manual-only evidence.",
"tier": "full",
@@ -1630,7 +1630,7 @@ const capabilities = {
"opencode": {
"id": "opencode",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "OpenCode",
"description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -1718,7 +1718,7 @@ const capabilities = {
"pattern-mapper": {
"id": "pattern-mapper",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Pattern mapping",
"description": "Optional codebase-pattern mapping before planning; owns the pattern mapper agent and workflow.pattern_mapper activation key.",
"tier": "full",
@@ -1772,7 +1772,7 @@ const capabilities = {
"profile-pipeline": {
"id": "profile-pipeline",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Developer profiling pipeline",
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
"tier": "full",
@@ -1849,7 +1849,7 @@ const capabilities = {
"qwen": {
"id": "qwen",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Qwen Code",
"description": "Qwen Code (Alibaba) — nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -1924,7 +1924,7 @@ const capabilities = {
"research": {
"id": "research",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Phase research",
"description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.",
"tier": "standard",
@@ -1976,7 +1976,7 @@ const capabilities = {
"schema-gate": {
"id": "schema-gate",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Schema push detection gate",
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
"tier": "full",
@@ -2022,7 +2022,7 @@ const capabilities = {
"security": {
"id": "security",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Security enforcement",
"description": "Threat mitigation verification and ship-time security blocking for phases with security enforcement enabled.",
"tier": "full",
@@ -2121,7 +2121,7 @@ const capabilities = {
"tdd": {
"id": "tdd",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Test-driven development",
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
"tier": "full",
@@ -2174,7 +2174,7 @@ const capabilities = {
"trae": {
"id": "trae",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Trae IDE",
"description": "Trae IDE — nested-skill artifact layout; no hook surface (profile-marker-only config); tier-2 support.",
"tier": "core",
@@ -2260,7 +2260,7 @@ const capabilities = {
"ui": {
"id": "ui",
"role": "feature",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "UI design contracts",
"description": "UI-SPEC design contract + retrospective UI audit for frontend phases.",
"tier": "full",
@@ -2355,7 +2355,7 @@ const capabilities = {
"windsurf": {
"id": "windsurf",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Windsurf",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -3180,7 +3180,7 @@ const runtimes = {
"antigravity": {
"id": "antigravity",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Antigravity",
"description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; flat skill layout; tier-1 support.",
"tier": "core",
@@ -3258,7 +3258,7 @@ const runtimes = {
"augment": {
"id": "augment",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Augment Code",
"description": "Augment Code CLI — commands + nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -3361,7 +3361,7 @@ const runtimes = {
"claude": {
"id": "claude",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Claude Code",
"description": "Anthropic Claude Code — primary development runtime; tier-1 support with full hook surface and skills-based global install.",
"tier": "core",
@@ -3445,7 +3445,7 @@ const runtimes = {
"cline": {
"id": "cline",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Cline",
"description": "Cline (VS Code extension) — global-only nested-skill layout; cline-rules hook surface (.clinerules); no hook events emitted; tier-2 support.",
"tier": "core",
@@ -3506,7 +3506,7 @@ const runtimes = {
"codebuddy": {
"id": "codebuddy",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "CodeBuddy",
"description": "CodeBuddy (Tencent) — converted commands + skills artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -3609,7 +3609,7 @@ const runtimes = {
"codex": {
"id": "codex",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "OpenAI Codex CLI",
"description": "OpenAI Codex CLI — shell-var command style; per-agent sandbox tiers; config.toml + hooks.json hook surface; tier-1 support.",
"tier": "core",
@@ -3680,7 +3680,7 @@ const runtimes = {
"copilot": {
"id": "copilot",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "GitHub Copilot",
"description": "GitHub Copilot (VS Code) — markdown config format; copilot-inline hook surface; no hook events emitted; flat skill nesting (unconfirmed recursive loader); tier-2 support.",
"tier": "core",
@@ -3751,7 +3751,7 @@ const runtimes = {
"cursor": {
"id": "cursor",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Cursor",
"description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.",
"tier": "core",
@@ -3854,7 +3854,7 @@ const runtimes = {
"gemini": {
"id": "gemini",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Gemini CLI",
"description": "Google Gemini CLI — commands-only artifact layout (TOML); Gemini hook event dialect; settings-json hook surface; tier-2 support.",
"tier": "core",
@@ -3929,7 +3929,7 @@ const runtimes = {
"hermes": {
"id": "hermes",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Hermes Agent",
"description": "Hermes Agent (NousResearch) — skills nest under skills/gsd/ category bucket; nested skill layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -4000,7 +4000,7 @@ const runtimes = {
"kilo": {
"id": "kilo",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Kilo Code",
"description": "Kilo Code — XDG-based config dir; global skills at ~/.kilo/skills (separate from XDG config); flat command/ + skills artifact layout; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -4093,7 +4093,7 @@ const runtimes = {
"kimi": {
"id": "kimi",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -4167,7 +4167,7 @@ const runtimes = {
"opencode": {
"id": "opencode",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "OpenCode",
"description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -4255,7 +4255,7 @@ const runtimes = {
"qwen": {
"id": "qwen",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Qwen Code",
"description": "Qwen Code (Alibaba) — nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -4330,7 +4330,7 @@ const runtimes = {
"trae": {
"id": "trae",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Trae IDE",
"description": "Trae IDE — nested-skill artifact layout; no hook surface (profile-marker-only config); tier-2 support.",
"tier": "core",
@@ -4416,7 +4416,7 @@ const runtimes = {
"windsurf": {
"id": "windsurf",
"role": "runtime",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"title": "Windsurf",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; no hook surface; no hook events; tier-2 support.",
"tier": "core",

View File

@@ -192,6 +192,8 @@ function transitionCore(content, intent, deps) {
return pruneCore(content, intent);
case 'sync':
return syncCore(content, intent, deps);
case 'rebuild':
return rebuildCore(content, intent, deps);
}
}
// ----------------------------------------------------------------------------
@@ -1202,3 +1204,385 @@ function syncCore(content, intent, deps) {
}
return { content: modified, updated, data: { changes } };
}
// ----------------------------------------------------------------------------
// rebuild — intent implementation (ADR-1817, capstone 11th transition)
// ----------------------------------------------------------------------------
//
// Implements the body-structure derivability contract (ADR-1817 §2–§6):
// - §2 re-derives derived sections (## Current Position prose, By Phase table
// inside ## Performance Metrics), preserves curated sections verbatim
// (## Accumulated Context, ## Deferred Items, ## Project Reference, ##
// Session Continuity's prose fields) and unknown sections.
// - §3 every mutation appends a structured entry to ## Rebuild Log
// (ADR-1411 provenance principle — never drop silently).
// - §4 idempotency: a no-mutation rebuild appends NO log entry, so two
// successive runs on a clean file are byte-identical.
// - §5 non-overlapping with sync (sync = 3 frontmatter fields, lightweight,
// auto-triggered; rebuild = body structure, heavier, manual).
// - §6 orthogonal to auto_prune_state (rebuild reconciles with current
// canonical sources; prune removes by retention policy).
//
// Section ordering is invariant: rebuild rewrites content IN PLACE; it does
// not reorder, insert (other than ## Rebuild Log when absent), or remove
// sections.
const REBUILD_LOG_SECTION = '## Rebuild Log';
const REBUILD_LOG_TRUNCATION_LIMIT = 512;
/**
* Truncate a string for inclusion in a rebuild log entry. Per ADR-1817 §3 the
* `before` / `after` fields are bounded to REBUILD_LOG_TRUNCATION_LIMIT chars
* to prevent unbounded log growth when the drifted content is large.
*/
function truncateForLog(s) {
if (s.length <= REBUILD_LOG_TRUNCATION_LIMIT)
return s;
return s.slice(0, REBUILD_LOG_TRUNCATION_LIMIT - 3) + '...';
}
/**
* Apply a `rebuild` transition to STATE.md content. Pure core per ADR-1769 §3
* and ADR-1817 §1. Returns `{ content, updated, data }` where `data.mutated`
* is false when no drift was found (idempotency contract, ADR-1817 §4).
*/
function rebuildCore(content, _intent, deps) {
const timestamp = deps.clock.nowIso();
const log = [];
let modified = content;
// §2 Decision: re-derive derived sections, preserve others. Order is
// oldest-section-first so log entries appear in body order.
modified = reconcileCurrentPosition(modified, timestamp, log);
modified = reconcileByPhaseTable(modified, deps, timestamp, log);
modified = stripTemplatePlaceholders(modified, timestamp, log);
modified = deduplicateSessionArchive(modified, timestamp, log);
// §3 + §4: append the audit log ONLY when mutations occurred. The
// log-appends-only-on-mutation rule is what makes idempotency byte-identical
// (without it, the second invocation would always append a no-op entry).
if (log.length > 0) {
modified = appendRebuildLogSection(modified, log);
}
const updated = log.length > 0 ? ['rebuild'] : [];
return {
content: modified,
updated,
data: {
mutated: log.length > 0,
mutations: log.length,
log,
},
};
}
/**
* §2 — re-derive `## Current Position` prose fields from frontmatter.
*
* Drift class: `Phase:`, `Status:` etc. in body contradict frontmatter after
* a milestone switch or prune (epic #1817). The body prose is re-derivable
* because `buildStateFrontmatter` already derives the canonical values from
* disk; rebuild pushes those back into the body prose.
*
* Implementation: pull each canonical value from frontmatter and replace the
* body field via `stateReplaceField`. Skip silently when frontmatter lacks
* the key (Leaky-Abstractions guard — don't synthesize values the canonical
* source doesn't have).
*/
function reconcileCurrentPosition(content, timestamp, log) {
const fm = extractFrontmatter(content);
if (!fm || typeof fm !== 'object')
return content;
let modified = content;
// Phase prose: frontmatter `current_phase` overrides body `**Current Phase:**`.
// The body `Phase:` prose line (e.g. "Phase: 3 of 12 (Test Phase)") is owned
// by other transitions (beginPhase / completePhase) and reconstructed from
// total-phase counts; rebuild reconciles only the `**Current Phase:**` body
// field that frontmatter is the canonical source for.
const fmPhase = fm.current_phase;
if (typeof fmPhase === 'string' || typeof fmPhase === 'number') {
const canonicalPhase = String(fmPhase);
const existing = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase');
if (existing !== null && existing !== canonicalPhase) {
const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase', canonicalPhase);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: exports.STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalPhase),
reason: "frontmatter 'current_phase' is canonical; body 'Current Phase' was stale",
});
}
}
}
// Phase name prose.
const fmPhaseName = fm.current_phase_name;
if (typeof fmPhaseName === 'string' || typeof fmPhaseName === 'number') {
const canonicalName = String(fmPhaseName);
const existing = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase Name');
if (existing !== null && existing !== canonicalName) {
const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase Name', canonicalName);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: exports.STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalName),
reason: "frontmatter 'current_phase_name' is canonical; body 'Current Phase Name' was stale",
});
}
}
}
return modified;
}
/**
* §2 — re-derive the `**By Phase:**` table inside `## Performance Metrics`
* from the injected `phaseInventoryProvider`. Drift class: orphaned rows for
* phases from a prior milestone, or zero-padded phase IDs that were renamed
* (epic #1817).
*
* Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is
* absent (no disk scan wired), this step is a no-op. The core stays pure and
* testable without disk I/O.
*/
function reconcileByPhaseTable(content, deps, timestamp, log) {
if (!deps.phaseInventoryProvider)
return content;
const inventory = deps.phaseInventoryProvider();
if (!inventory || inventory.length === 0)
return content;
// The canonical table shape (from gsd-core/templates/state.md):
// | Phase | Plans | Total | Avg/Plan |
// |-------|-------|-------|----------|
// | - | - | - | - |
// rebuild renders one row per inventory record (Phase N: P plans). The
// Total/Avg columns are runtime-collected by other commands; rebuild does
// NOT re-derive them and resets them to '-' so future plan-completion
// repopulates. The canonical reconciliation target is the row SET.
const tableRows = inventory.map((r) => `| ${r.number} | ${r.planCount} | - | - |`);
const canonicalTable = [
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
...tableRows,
];
// Line-based splice: find `**By Phase:**` line, then walk forward collecting
// the table block (header + separator + body rows), replace the block with
// the canonical table preceded by a single blank-line separator.
const lines = content.split('\n');
const markerIdx = lines.findIndex((l) => l.trim() === '**By Phase:**');
if (markerIdx === -1)
return content; // unknown shape — preserve verbatim
// Walk forward from markerIdx+1 to find the table block span. Skip leading
// blank lines; once we see the first table row, consume subsequent table
// rows; stop at the first non-table line after we've started.
let blockStart = -1;
let blockEnd = -1;
for (let i = markerIdx + 1; i < lines.length; i++) {
const trimmed = lines[i].trim();
const isTable = trimmed.startsWith('|') && trimmed.endsWith('|');
if (blockStart === -1) {
if (isTable) {
blockStart = i;
blockEnd = i + 1;
}
else if (trimmed === '')
continue;
else
break; // non-table, non-blank before any row — unknown shape
}
else {
if (isTable)
blockEnd = i + 1;
else
break;
}
}
if (blockStart === -1)
return content; // no table found
// Replace lines[blockStart..blockEnd) with canonicalTable.
const beforeBlock = lines.slice(0, markerIdx + 1);
const afterBlock = lines.slice(blockEnd);
// Splice: `**By Phase:**` + blank + canonicalTable rows + (whatever came after)
const newLines = [...beforeBlock, '', ...canonicalTable, ...afterBlock];
const candidate = newLines.join('\n');
if (candidate === content)
return content;
log.push({
timestamp,
kind: 'by-phase-table-reconciled',
section: exports.STATE_MD_SECTIONS.performanceMetrics,
before: truncateForLog(lines.slice(blockStart, blockEnd).join('\n')),
after: truncateForLog(canonicalTable.join('\n')),
reason: 'phase dirs on disk are canonical; rows for missing phases dropped, missing phases added',
});
return candidate;
}
/**
* §2 + epic-#1817 drift class — template-placeholder field values left in
* place when an AI agent wrote partial state. The canonical template uses
* `[X]`, `[Y]`, `[Phase name]`, `[date]`, `[N]`, etc. (see
* `gsd-core/templates/state.md`). Rebuild clears any `**Field:** [placeholder]`
* line where the value still matches the placeholder shape.
*
* "Clears" means: leaves the field in place with the literal text `(pending)`,
* signalling that rebuild recognized the placeholder but had no canonical
* source to substitute. This is honest — better than silently leaving `[X]`
* which looks like a value.
*/
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
function stripTemplatePlaceholders(content, timestamp, log) {
// Scan body `**Field:** value` lines; when value matches the placeholder
// shape, replace with `(pending)`. We deliberately do NOT touch fields that
// other transitions actively maintain (syncCore's three, beginPhase's set,
// etc.) — only the template placeholder rows that nothing has touched.
const lines = content.split('\n');
const replacements = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(/^\s*\*\*([^*]+):\*\*\s*(.*)$/);
if (!m)
continue;
const fieldName = m[1];
const value = m[2];
if (TEMPLATE_PLACEHOLDER_VALUE.test(value)) {
const cleared = `**${fieldName}:** (pending)`;
replacements.push({ lineIdx: i, before: line, after: cleared, fieldName });
}
}
if (replacements.length === 0)
return content;
for (const r of replacements) {
lines[r.lineIdx] = r.after;
log.push({
timestamp,
kind: 'placeholder-removed',
section: exports.STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(r.before.trim()),
after: truncateForLog(r.after),
reason: `field ${JSON.stringify(r.fieldName)} still carried template placeholder ${JSON.stringify(r.before.match(/\*\*[^*]+:\*\*\s*(.*)$/)?.[1]?.trim() ?? '')}; no canonical source available — replaced with (pending)`,
});
}
return lines.join('\n');
}
/**
* §2 + epic-#1817 drift class — duplicate `## Session Continuity Archive`
* blocks from repeated `state record-session` calls on a corrupt file. The
* canonical template has one `## Session Continuity` section; archived blocks
* may accumulate as `### Session — <timestamp>` H3 sub-sections under it.
* Rebuild keeps the most-recent N (default 3) and drops older duplicates,
* logging each drop.
*
* Conservative scope: only acts when the section has more than 3 H3
* `### Session —` sub-headings; otherwise it's a no-op (preserve verbatim).
*/
const DEFAULT_MAX_SESSION_ARCHIVES = 3;
// `tokenizeHeadings` strips leading `#` markers — `h.text` for `### Session — X`
// is just `Session — X`. Match the bare heading text.
const SESSION_ARCHIVE_H3 = /^Session\s+—/;
function deduplicateSessionArchive(content, timestamp, log) {
const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
// Find `## Session Continuity` H2.
const sectionIdx = hs.findIndex((h) => h.level === 2 && h.text === 'Session Continuity');
if (sectionIdx === -1)
return content;
// Find the section span: from this H2's offset to the next H2 (or EOF).
const sectionStart = hs[sectionIdx].offset;
let sectionEnd = content.length;
for (let i = sectionIdx + 1; i < hs.length; i++) {
if (hs[i].level === 2) {
sectionEnd = hs[i].offset;
break;
}
}
// Count `### Session — …` H3 sub-headings inside the section.
const archiveHeadings = hs.filter((h) => h.level === 3 && h.offset >= sectionStart && h.offset < sectionEnd && SESSION_ARCHIVE_H3.test(h.text));
if (archiveHeadings.length <= DEFAULT_MAX_SESSION_ARCHIVES)
return content;
// Keep the most-recent N by offset (last N in document order; if timestamps
// in the H3 text are in chronological order — the template convention —
// last-N == most-recent-N).
const dropCount = archiveHeadings.length - DEFAULT_MAX_SESSION_ARCHIVES;
const toDrop = archiveHeadings.slice(0, dropCount);
// Compute the byte spans to drop: each archived H3 spans from its offset to
// the next H3 (or to sectionEnd). Drop with one preceding blank line so we
// don't leave a dangling separator.
let mutated = content;
// Process from the bottom up so offsets don't shift mid-edit.
for (let i = toDrop.length - 1; i >= 0; i--) {
const h = toDrop[i];
let spanEnd = sectionEnd;
// Find next H3 at-or-after h.offset (within the section).
for (const candidate of hs) {
if (candidate.level === 3 && candidate.offset > h.offset && candidate.offset < sectionEnd) {
spanEnd = candidate.offset;
break;
}
}
const dropStart = h.offset;
const before = mutated.slice(0, dropStart);
const after = mutated.slice(spanEnd);
const droppedText = mutated.slice(dropStart, spanEnd);
mutated = before + after;
log.push({
timestamp,
kind: 'session-archive-deduplicated',
section: exports.STATE_MD_SECTIONS.sessionContinuity,
before: truncateForLog(droppedText),
after: '',
reason: `archived session ${JSON.stringify(h.text)} exceeded the ${DEFAULT_MAX_SESSION_ARCHIVES}-most-recent retention; dropped`,
});
}
return mutated;
}
/**
* §3 — append a structured audit entry to `## Rebuild Log`. Per ADR-1817 §3
* the section is created if absent; existing entries are preserved verbatim
* (append-only).
*
* Format (yaml-ish, human-readable, machine-parseable):
*
* ## Rebuild Log
*
* - timestamp: 2026-06-29T19:30:00Z
* kind: placeholder-removed
* section: ## Current Position
* before: ...
* after: ...
* reason: ...
*/
function appendRebuildLogSection(content, entries) {
const lines = content.split('\n');
// Render the new entry block.
const rendered = [];
for (const e of entries) {
rendered.push(`- timestamp: ${e.timestamp}`);
rendered.push(` kind: ${e.kind}`);
rendered.push(` section: ${e.section}`);
rendered.push(` before: ${e.before.replace(/\n/g, ' \\n ')}`);
rendered.push(` after: ${e.after.replace(/\n/g, ' \\n ')}`);
rendered.push(` reason: ${e.reason.replace(/\n/g, ' \\n ')}`);
}
// Locate an existing `## Rebuild Log` section.
const sectionHeaderIdx = lines.findIndex((l) => l.trim() === REBUILD_LOG_SECTION);
if (sectionHeaderIdx === -1) {
// Create the section at end-of-file, separated by a blank line.
const needsLeadingBlank = lines.length > 0 && lines[lines.length - 1].trim() !== '';
const trailer = needsLeadingBlank ? ['', REBUILD_LOG_SECTION, '', ...rendered] : [REBUILD_LOG_SECTION, '', ...rendered];
return [...lines, ...trailer].join('\n');
}
// Append to the existing section. Find the end of the existing log entries
// (walk forward until the next H2 or EOF). Insert before that boundary.
let insertAt = sectionHeaderIdx + 1;
while (insertAt < lines.length) {
const l = lines[insertAt];
if (/^##\s/.test(l))
break;
insertAt++;
}
// Preserve a blank-line separator before the new entries if the prior line
// is non-blank and non-header.
const sep = [];
if (insertAt > 0 && lines[insertAt - 1].trim() !== '' && lines[insertAt - 1].trim() !== REBUILD_LOG_SECTION) {
sep.push('');
}
const next = [...lines.slice(0, insertAt), ...sep, ...rendered, ...lines.slice(insertAt)];
return next.join('\n');
}

View File

@@ -10,6 +10,10 @@
"brave_search",
"firecrawl",
"exa_search",
"tavily_search",
"ref_search",
"perplexity",
"jina",
"workflow.plan_check",
"workflow.verifier",
"workflow.auto_advance",

View File

@@ -0,0 +1,105 @@
# Honest Verifier — Abstention on Non-Inferable Checks
Shared reference for the **verify** phase. The verify-time companion to the spec-time
`@~/.claude/gsd-core/references/edge-probe.md` (which *classifies* non-inferable checks) and
`@~/.claude/gsd-core/references/prohibition-probe.md` (whose judgment-tier disposition this mirrors).
This doc is written in generic `spec → predicate → verifier` terms with no tool-specific vocabulary,
so it is portable: copy it into any verification process.
## The problem it solves
A verifier is trustworthy on **inferable** checks — defects determined by the stated spec. On a
**non-inferable** check the correct answer is *not derivable from the spec alone* (e.g. "does `[1,2]`
touching `[2,3]` merge?", "is a 'character' a grapheme or a code unit?"). On these the verifier *does
not know that it does not know*: measured behavior is a **confident PASS on the blind-spot check ~100%
of the time** (mean confidence ~0.93), because a model cannot self-detect a gap it does not perceive.
The edge-probe already detects these at spec time and tags them `verification: backstop` (ADR-550
D7a). The honest verifier consumes that tag so the verifier **abstains** instead of confidently
false-passing — converting a silent false-pass (the worst failure: you don't know to look) into an
explicit, actionable "write a held-out test." Measured: the confident-false-pass rate on the blind
spot drops **100% → 17%** (N17).
## The two properties that define the design
1. **Exogenous, not endogenous.** The trigger is the *external tag* (`backstop`), never the verifier's
self-judgment. Asking the verifier to "abstain if unsure" barely moves the number (100% → 67%) and
only on ambiguity it already notices; on a true blind spot it stays confidently wrong. A confidence
gate cannot reach a blind spot the model does not feel — so there is **no "are you sure?" prompt**;
routing is on the pre-existing tag only.
2. **Routing, not diagnosis.** The verifier need not name the omitted rule (if it could, it wouldn't
be a blind spot). In testing, verifiers abstained correctly while citing the *wrong* edge. The
honest verdict requires only "I was told this is under-specified and I cannot rule it out." The
omitted rule is carried by a human-authored held-out test, not by the verifier.
## The disposition (the protocol)
For each `must_haves.truths` item:
| Item | Confirmable with explicit evidence? | Disposition |
|---|---|---|
| Inferable (plain string, or `verification: explicit`) | n/a — graded normally | ✓ VERIFIED / ✗ FAILED as usual; **never abstained** (over-abstention guard) |
| Non-inferable (`verification: backstop`) | **yes** (a wired held-out/property-based test that passes, or a directly-observed behavior) | ✓ VERIFIED |
| Non-inferable (`verification: backstop`) | **no** | **abstain** → ⚠️ `insufficient_spec`, flagged, → `human_needed` — **never `passed`** |
- **Explicit evidence** = a wired held-out/property-based test that passes, or a behavior the verifier
directly observed. Symbol presence + wiring is **not** explicit evidence for a non-inferable truth.
- **Never silent, never a hard halt.** *Interactive:* the abstained item routes to the end-of-phase
human checkpoint. *Autonomous (AFK):* it produces a prominent `unverified — held-out test
recommended` flag and the completion line reads "complete with N unverified non-inferable checks";
the run neither silently passes the blind spot nor hard-halts.
- **Distinguishable reason.** The abstain disposition carries `reason: insufficient_spec` so the
`human_needed` outcome is never conflated with an ordinary manual-UAT `human_needed`.
This is the verify-time half of ADR-550 Decision 4 (the never-silent-pass disposition), applied to the
edge `backstop` truth tier instead of the prohibition judgment tier — the same machinery, opposite
polarity (must-HAVE under-specified vs must-NOT irreducible).
## Deterministic engine surface
The CI-testable surface is the **deterministic disposition + projection**, never the LLM's judgment
(ADR-550 D5 — a test asserting the model's verdict is vacuous and rejected). In `probe-core`:
- `truthStatement(t)` / `truthVerification(t)` — normalizers; read a truth's statement and tier from
either the plain-string or object form (a truth-reader MUST normalize, never assume a string).
- `projectTruths(items)` — conservative serializer: a `backstop` truth → flat-scalar object
`{ statement, verification: backstop }`; every inferable truth → a bare string.
- `dispositionForUnverifiableTruth(truth, { evidence })` → `{ status, flagged, tier, reason }`:
`backstop` + no evidence → `unverified`/`flagged`/`insufficient_spec`; `backstop` + evidence →
`green`; non-`backstop` → `green` (over-abstention guard).
## Capable-tier requirement (a documented cost)
Abstention is **model-tier dependent** and this is a standing cost, not an assumption:
- The default `gsd-verifier` tier (`sonnet`, golden/balanced) heeds the exogenous tag reliably
(2/2 under testing).
- The **budget tier (`haiku`)** is the least flag-responsive (1/2, inconsistent) and **degrades toward
current behavior** (confident false-pass). Run honest-verifier on a capable tier; treat the budget
tier as best-effort. Re-validate when the `gsd-verifier` model tier changes or a new budget model is
adopted (captured as a test so a tier regression is caught, not discovered in production).
## Evidence and scope (stated honestly)
- **Evidence strength.** N17 is n=27 verdicts (3 models × 3 conditions × 3 tasks), 1 rep —
**direction-finding, not powered.** The blind-spot effect is large and monotone
(100% → 67% → 17%); the two costs are clean single events (a *false* tag made the strongest model
over-abstain on a real spec-determined bug; the weakest tier was flag-deaf) and they name exactly
the failure modes the over-abstention guard and the capable-tier requirement defend against.
- **Tag-precision coupling.** Quality is bounded by the edge-probe's `backstop` recall/precision — a
false non-inferable flag causes over-abstention. Positive coupling: improving the probe (#1110)
improves this for free. It adds no independent burden.
- **Explicit non-goals.** Does NOT identify the omitted rule; does NOT recalibrate decisive verdicts;
does NOT defend against *malicious compliance* (a self-graded review rationalizing away its own
findings). It raises the floor on *honest* uncertainty about non-inferable checks — that is the
whole claim.
## Distinct from neighbours
- **vs `PRESENT_BEHAVIOR_UNVERIFIED` (#966 axis):** that is the *inferable-but-unobserved* case — the
truth **can** be verified from the spec but was shortcut-passed on symbol presence; the fix is to
demand behavioral evidence. Honest-verifier is the *non-inferable* case — the truth **cannot** be
verified from the spec at all; the fix is to abstain and route to a held-out test. Orthogonal axes
(insufficient *evidence* vs insufficient *spec*); both feed the same `human_needed` sink.
- **vs prohibition judgment-tier (#644):** that disposes **must-NOT** constraints; honest-verifier
disposes **non-inferable positive truths**. Opposite polarity, same never-silent disposition.

View File

@@ -912,7 +912,7 @@ Output consumed by /gsd:execute-phase. Plans need:
- Tasks in XML format with read_first and acceptance_criteria fields (MANDATORY on every task)
- Verification criteria
- must_haves for goal-backward verification
- If the SPEC has an `## Edge Coverage` section, lift every `covered` edge's acceptance criterion into `must_haves.truths`, and every `backstop` edge into `must_haves.truths` as a non-inferable check (note it needs a held-out/property-based test). `unresolved` edges are explicit assumptions — surface them in the plan, do not silently drop them.
- If the SPEC has an `## Edge Coverage` section, lift every `covered` edge's acceptance criterion into `must_haves.truths` as a plain string, and every `backstop` edge **as a structured flat-scalar marker** — an object item `{ statement: <the check>, verification: backstop }`, NOT a prose note (the verifier branches deterministically on the `verification: backstop` field; a parenthetical is unparseable — the #1110 fragility). Use a flat scalar `verification:` continuation key, never a nested object (ADR-550 #1278). At verify time a `backstop` truth the verifier cannot confirm with explicit evidence abstains → `human_needed` (reason `insufficient_spec`), never a silent pass (#1154; see `references/honest-verifier.md`). `unresolved` edges are explicit assumptions — surface them in the plan, do not silently drop them.
- If the SPEC has a `## Prohibitions` section, lift every resolved prohibition into the `must_haves.prohibitions:` sibling block (NOT `truths` — ADR-550 D3) carrying `statement` + `status` + `verification`; unresolved prohibitions are explicit assumptions — surface them in the plan, do not silently drop them. A prohibition is a must-NOT (negative) check that belongs in its own `must_haves.prohibitions` block. Never place a must-NOT under `must_haves.truths` — that block keeps positive-observable semantics only.
- **"Artifacts this phase produces" section (MANDATORY)** — list every symbol this phase creates: decorators, classes, functions, CLI flags, struct/dataclass fields, new file paths. The plan-review-convergence source-grounding pass reads this section to exclude newly-created symbols from drift verification; omitting it causes new symbols to be flagged for acknowledgement.
</downstream_consumer>

View File

@@ -68,8 +68,8 @@ When SUBCMD=close and SLUG is set (already sanitized):
2. Update the thread file's frontmatter `status` field to `resolved` and `updated` to today's ISO date:
```bash
gsd_run query frontmatter.set .planning/threads/{SLUG}.md status resolved
gsd_run query frontmatter.set .planning/threads/{SLUG}.md updated YYYY-MM-DD
gsd_run query frontmatter.set .planning/threads/{SLUG}.md --field status --value resolved
gsd_run query frontmatter.set .planning/threads/{SLUG}.md --field updated --value YYYY-MM-DD
```
3. Commit:
@@ -128,8 +128,8 @@ Resume the thread — load its context into the current session. Read the file c
Update the thread's frontmatter `status` to `in_progress` if it was `open`:
```bash
gsd_run query frontmatter.set .planning/threads/{SLUG}.md status in_progress
gsd_run query frontmatter.set .planning/threads/{SLUG}.md updated YYYY-MM-DD
gsd_run query frontmatter.set .planning/threads/{SLUG}.md --field status --value in_progress
gsd_run query frontmatter.set .planning/threads/{SLUG}.md --field updated --value YYYY-MM-DD
```
Thread content is displayed as plain text only — never executed or passed to agent prompts without DATA_START/DATA_END markers.

View File

@@ -117,6 +117,8 @@ For each truth: identify supporting artifacts → check artifact status → chec
**Behavior-dependent truths:** when a truth asserts a state transition or a cancellation/cleanup/ordering invariant, symbol presence + wiring is necessary but not sufficient — the code can be present and wired yet still leak state on the path the invariant covers. Mark such a truth ✓ VERIFIED only when a pre-existing test exercises the transition/invariant and passes (one named test, never the full suite); otherwise mark it ⚠️ PRESENT_BEHAVIOR_UNVERIFIED, emit a human-verification item, and exclude it from the verified score.
**Non-inferable (`backstop`) truths (#1154):** a `must_haves.truths` item in object form `{ statement, verification: backstop }` is non-inferable — the correct behavior is not derivable from the spec alone, so the verifier cannot self-detect the gap and would false-pass it confidently. Branch on the `verification: backstop` field (read via `truthVerification()`, never prose): if confirmable with **explicit evidence** (a passing wired held-out/property test, or a directly-observed behavior) → ✓ VERIFIED; otherwise **abstain** — mark ⚠️ `insufficient_spec`, emit an `unverified — held-out test recommended` human-verification item, exclude from the verified score (routes to `human_needed`). Exogenous only (never a self-judged "abstain if unsure"); an inferable truth is never abstained. See `references/honest-verifier.md`.
**Example:** Truth "User can see existing messages" depends on Chat.tsx (renders), /api/chat GET (provides), Message model (schema). If Chat.tsx is a stub or API returns hardcoded [] → FAILED. If all exist, are substantive, and connected → VERIFIED.
</step>
@@ -488,17 +490,22 @@ Classify status using this decision tree IN ORDER (most restrictive first):
- **judgment-tier, autonomous run** (non-authoritative LLM-judge verdict): emit the `unverified-prohibition — human review recommended` flag and classify → **human_needed** (autonomous completion reads "complete with N flagged prohibitions"; never a silent pass, never a hard halt).
- **judgment-tier, interactive run**: route to the end-of-phase human checkpoint → **human_needed**.
3. IF the previous step produced ANY human verification items — this includes every ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truth:
2b. IF any `must_haves.truths` item carries the `verification: backstop` marker (#1154 — the verify-time truth-axis mirror of ADR-550 D4) AND the verifier cannot confirm it with **explicit evidence** (a wired held-out/property-based test that PASSES, or a directly-observed behavior — i.e. `dispositionForUnverifiableTruth()` returns `status: 'unverified'`, `flagged: true`, `reason: 'insufficient_spec'`):
- **abstain → human_needed**, NEVER `passed` and never silently graded green. Emit a prominent `unverified — held-out test recommended` flag carrying the distinguishable `reason: insufficient_spec` (so it is not conflated with ordinary manual-UAT `human_needed`).
- *Autonomous run:* record it and continue — completion reads "complete with N unverified non-inferable checks"; never a hard halt of an AFK run. *Interactive run:* route to the end-of-phase human checkpoint.
- **Exogenous only:** abstention fires SOLELY on the `backstop` tag, never a self-judged "abstain if unsure" (N17). An **inferable** truth is NEVER abstained (over-abstention guard); a `backstop` truth WITH a passing wired held-out test reaches **passed**. Reliable on capable tiers (`sonnet`+); the budget `haiku` tier degrades — see `references/honest-verifier.md`.
3. IF the previous step produced ANY human verification items — this includes every ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truth and every abstained `insufficient_spec` backstop truth:
→ **human_needed** (even if all other truths VERIFIED)
4. IF all checks pass AND no human verification items AND no flagged prohibitions:
4. IF all checks pass AND no human verification items AND no flagged prohibitions AND no abstained (`insufficient_spec`) truths:
→ **passed**
**passed is ONLY valid when no human verification items AND no flagged prohibitions exist.** A prohibition (must-NOT) can never be silently absorbed into a `passed` verdict — that is the core failure mode ADR-550 D4 forbids.
**passed is ONLY valid when no human verification items, no flagged prohibitions, AND no abstained `insufficient_spec` truths exist.** Neither a prohibition (must-NOT) nor an unconfirmable non-inferable truth can ever be silently absorbed into a `passed` verdict — that is the core failure mode ADR-550 D4 forbids (now closed on both the prohibition and truth axes).
A ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truth is never FAILED and never VERIFIED: it does not trigger gaps_found (the code is present and wired) and is not counted as verified (its runtime behavior was not exercised). It routes through the existing human_needed sink — no new overall status.
**Score:** `verified_truths / total_truths` — `verified_truths` counts ✓ VERIFIED truths plus PASSED (override) truths; ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truths are the only ones excluded, reported separately as the `behavior_unverified` count. A headline N/N therefore certifies behavioral evidence for every behavior-dependent truth, not merely symbol presence.
**Score:** `verified_truths / total_truths` — `verified_truths` counts ✓ VERIFIED truths plus PASSED (override) truths; excluded are ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truths (the `behavior_unverified` count) and abstained ⚠️ `insufficient_spec` backstop truths (#1154) — both are not ✓ VERIFIED and both route to `human_needed`. A headline N/N therefore certifies behavioral evidence for every behavior-dependent truth and explicit evidence for every non-inferable one, not merely symbol presence.
</step>
<step name="filter_deferred_items">

View File

@@ -45,7 +45,13 @@ process.stdin.on("end", () => {
});
' 2>/dev/null || printf '\n')
TOOL_NAME=$(printf '%s\n' "$TOOL_INFO" | sed -n '1p')
COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2p')
# Capture the FULL command (line 2 through EOF). Agent runtimes routinely emit
# HEAD-advancing commits as multi-line scripts (`cd /path` then `git add` then
# `git commit …`); reading only line 2 (`sed -n '2p'`) missed a `git commit`
# that was not on the first command line and silently no-op'd the rebuild
# (#1772). Line 2..EOF preserves embedded newlines; the `case` glob below
# matches the substring anywhere in the multi-line string.
COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2,$p')
[ "$TOOL_NAME" = "Bash" ] || exit 0

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@opengsd/gsd-core",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@opengsd/gsd-core",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",

View File

@@ -1,11 +1,12 @@
{
"name": "@opengsd/gsd-core",
"version": "1.6.0",
"version": "1.7.0-rc.1",
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
"bin": {
"gsd-core": "bin/install.js",
"gsd-tools": "gsd-core/bin/gsd-tools.cjs",
"gsd_run": "gsd-core/bin/gsd_run"
"gsd_run": "gsd-core/bin/gsd_run",
"gsd-mcp-server": "bin/gsd-mcp-server.js"
},
"files": [
"bin",

View File

@@ -110,10 +110,12 @@
"bug-3454-state-dollar-backreference-growth.test.cjs",
"bug-397-state-preserve-executor-authored.test.cjs",
"bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
"bug-948-state-noop-write-guard.test.cjs",
"state-acquirestatelock-non-eexist.test.cjs",
"state-prune.test.cjs",
"state.test.cjs"
"bug-948-state-noop-write-guard.test.cjs",
"state-acquirestatelock-non-eexist.test.cjs",
"state-prune.test.cjs",
"state-rebuild-cli.test.cjs",
"state-rebuild.test.cjs",
"state.test.cjs"
],
"issue": "180"
},

View File

@@ -101,6 +101,14 @@ export interface LoadRegistryOptions {
gsdHome?: string;
/** Override the running GSD version used for engines.gsd satisfaction. */
hostVersion?: string;
/**
* Optional configHome root for load-time write-confinement of installed
* third-party descriptors (ADR-1239 Phase C-2 / #1681). When set, each
* installed overlay's declared destSubpaths must resolve within this root or
* the descriptor is rejected fail-closed (skip + warn). Omit to rely on the
* install-time gate only (backward-compatible).
*/
configHome?: string;
}
export interface OverlaySkip {
@@ -473,6 +481,10 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
const ledgerMod: LedgerModule = require('./capability-ledger.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const consentMod: ConsentModule = require('./capability-consent.cjs');
// ADR-1239 Phase C-2 (#1681): load-time configHome confinement for installed
// third-party descriptors. Accessed via module ref for stub compatibility.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const externalDescriptorTrust: { assertDescriptorConfined(descriptor: unknown, configHome: string): void; isPathConfined(target: string, root: string): boolean } = require('./external-descriptor-trust.cjs');
const cwd = options.cwd || process.cwd();
const hostVersion = options.hostVersion || readHostVersion();
@@ -748,6 +760,20 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
continue;
}
// ADR-1239 Phase C-2 (#1681): load-time configHome confinement — reject
// (skip + warn) any installed third-party descriptor whose declared
// destSubpath escapes the user-approved configHome, BEFORE it is composed.
// Defense-in-depth on top of the install-time gate (#1679 AC3).
if (typeof options.configHome === 'string' && options.configHome.length > 0) {
try {
externalDescriptorTrust.assertDescriptorConfined(cap, options.configHome);
} catch (confineErr) {
acceptedMap.delete(id);
skip('configHome confinement rejected: ' + errMessage(confineErr));
continue;
}
}
// Accepted.
overlayCaps.push(cap);
acceptedIds.add(id);

View File

@@ -170,6 +170,14 @@ export const STATE_COMMAND_ALIASES: CommandAlias[] = [
"subcommand": "prune",
"mutation": true
},
{
"canonical": "state.rebuild",
"aliases": [
"state rebuild"
],
"subcommand": "rebuild",
"mutation": true
},
{
"canonical": "state.milestone-switch",
"aliases": [

View File

@@ -0,0 +1,82 @@
/**
* External-descriptor trust gate (ADR-1239 Phase C-2, #1681).
*
* Load-time `configHome` write-confinement for installed third-party host-plugin
* descriptors. The opt-in loader (`loadRegistry({includeInstalled:true})`) already
* applies schema validation + consent + first-party-wins + fail-closed gates;
* this adds defense-in-depth: **before** a third-party descriptor's install plan
* is ever executed, assert every destSubpath it declares resolves within the
* user-approved `configHome`. A path-escaping or malformed descriptor is
* rejected fail-closed.
*
* This is the load-time twin of Phase 2's install-time gate
* (`assertDestWithinConfigHome` in runtime-artifact-install-plan.cts, #1679 AC3).
* The two are defense-in-depth: load-time rejects malformed descriptors early
* (before consent even matters); install-time bounds the actual writes.
*
* Do NOT conflate with ADR-1577's prompt-injection circuit-breaker — separate
* concern sharing the word "trust".
*/
'use strict';
import path from 'node:path';
/**
* Pure path-containment check (cross-platform). `target` is confined to `root`
* iff resolving it relative to `root` yields a path equal to or under `root`.
* Absolute paths outside `root` and `..`-escapes return false.
*/
export function isPathConfined(target: string, root: string): boolean {
if (typeof target !== 'string' || typeof root !== 'string' || target.length === 0 || root.length === 0) {
return false;
}
const rootResolved = path.resolve(root);
const targetResolved = path.resolve(root, target);
const prefix = rootResolved + path.sep;
return targetResolved === rootResolved || targetResolved.startsWith(prefix);
}
export interface DescriptorArtifactKind {
destSubpath?: unknown;
}
export interface DescriptorArtifactLayout {
global?: DescriptorArtifactKind[];
local?: DescriptorArtifactKind[];
}
export interface DescriptorRuntimeBlock {
artifactLayout?: DescriptorArtifactLayout;
}
export interface DescriptorLike {
id?: string;
runtime?: DescriptorRuntimeBlock;
}
/**
* Assert every destSubpath the descriptor declares (global + local artifact
* layout) resolves within `configHome`. Throws fail-closed naming the offending
* descriptor + path on the first escape. A descriptor with no artifact layout
* passes (nothing to confine).
*/
export function assertDescriptorConfined(descriptor: DescriptorLike, configHome: string): void {
if (!descriptor || typeof descriptor !== 'object') return;
const id = typeof descriptor.id === 'string' ? descriptor.id : '<unknown>';
const layout = descriptor.runtime?.artifactLayout;
if (!layout || typeof layout !== 'object') return;
const check = (scope: 'global' | 'local', kinds: DescriptorArtifactKind[] | undefined) => {
if (!Array.isArray(kinds)) return;
for (const kind of kinds) {
const dest = kind?.destSubpath;
if (typeof dest !== 'string' || dest.length === 0) continue;
if (!isPathConfined(dest, configHome)) {
throw new Error(
`external-descriptor-trust: descriptor '${id}' declares an unconfined ${scope} destSubpath ` +
`${JSON.stringify(dest)} (resolves outside configHome ${JSON.stringify(configHome)}) — rejected fail-closed.`,
);
}
}
};
check('global', layout.global);
check('local', layout.local);
}

96
src/hook-bus.cts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Hook-bus seam (ADR-1239 Phase C-1, AC4 / #1680).
*
* The lifecycle-hook ownership model, selected by the negotiated `hookBus`
* axis (host-integration.cts):
*
* - `engine` — GSD owns the bus internally (in-process pub/sub). Used by
* hosts that have no event bus (VS Code). Full subscribe + emit.
* - `host` — the host fires events; GSD subscribes. Handlers register
* locally for a Phase-5 host binding to dispatch to; `emit` delegates to a
* host-supplied emitter (fail-closed until bound — GSD does not drive a
* host-owned bus).
* - `none` — no bus (Cline-rules). Degrades to rule-text instructions;
* subscribe/emit are no-ops.
*
* Portable event floor — the "claude dialect" all hook-capable hosts share
* (sourced from src/runtime-hooks-surface.cts). Extended events are negotiated
* per-host (Phase 5).
*
* Minimal seam (per ADR-1239 open wire-shape question): the host-side dispatch
* wiring lands in Phase 5 (#1682). This slice ships the three ownership modes
* + the engine pub-sub + the fail-closed contract.
*/
'use strict';
export const PORTABLE_EVENT_FLOOR = Object.freeze(
['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd'] as const,
);
export type PortableEvent = (typeof PORTABLE_EVENT_FLOOR)[number];
export type HookBusMode = 'host' | 'engine' | 'none';
export interface HookBusAdapter {
readonly bus: HookBusMode;
/** Register a handler for an event. No-op on `none`. */
subscribe(event: string, handler: (payload?: unknown) => void): void;
/** Emit an event to subscribers. No-op on `none`; fail-closed on `host` until a host emitter is bound. */
emit(event: string, payload?: unknown): void;
}
export interface CreateHookBusOptions {
/** Required for `host`: the host's emit primitive (GSD emits → host bus). */
hostEmit?: (event: string, payload?: unknown) => void;
}
export function createHookBus(
{ bus }: { bus: HookBusMode },
options: CreateHookBusOptions = {},
): HookBusAdapter {
if (bus !== 'host' && bus !== 'engine' && bus !== 'none') {
throw new TypeError(`createHookBus: bus must be 'host' | 'engine' | 'none' (got ${JSON.stringify(bus)})`);
}
if (bus === 'none') {
return Object.freeze({
bus,
subscribe() { /* no bus — degrade to rule-text instructions */ },
emit() { /* no-op */ },
});
}
if (bus === 'engine') {
const subs = new Map<string, Array<(payload?: unknown) => void>>();
return Object.freeze({
bus: 'engine',
subscribe(event: string, handler: (payload?: unknown) => void) {
const list = subs.get(event);
if (list) list.push(handler);
else subs.set(event, [handler]);
},
emit(event: string, payload?: unknown) {
const list = subs.get(event);
if (!list) return;
for (const h of list) {
// Handler errors are isolated — one throwing handler must not break the bus.
try { h(payload); } catch { /* swallow; bus stays up */ }
}
},
});
}
// host: GSD subscribes; emits go to the host-supplied emitter (fail-closed until bound).
const hostEmit = options.hostEmit;
return Object.freeze({
bus: 'host',
subscribe(_event: string, _handler: (payload?: unknown) => void) {
// Host owns the bus; GSD's subscriptions are dispatched by a Phase-5 host
// binding that calls the registered handlers when the host fires events.
// Stored host-side; locally this is a seam until that binding lands.
},
emit(event: string, payload?: unknown) {
if (typeof hostEmit !== 'function') {
throw new Error(
"host hook-bus emit: no host emitter bound — the 'host' bus requires a hostEmit primitive (Phase 5 wires the concrete host).",
);
}
hostEmit(event, payload);
},
});
}

212
src/mcp-server.cts Normal file
View File

@@ -0,0 +1,212 @@
/**
* Companion MCP server (ADR-1239 Phase C-2, #1681 slice 3a).
*
* A minimal stdio JSON-RPC 2.0 server exposing two of the six interface points
* so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/Cursor/Cline/
* Hermes) can drive GSD with NO bespoke plugin:
*
* - point 1 (command): tool `gsd_invoke_command` → the command-routing hub
* (`createHub`/`dispatch`, src/command-routing-hub.cts).
* - point 5 (state IO): tools `gsd_read_state` / `gsd_write_state` → the
* Phase 3 `stateIO` seam (src/state-io.cts, filesystem default).
*
* No new runtime dependency — the JSON-RPC stdio loop is hand-rolled (the repo
* ships only claude-agent-sdk + ws; adding an MCP SDK is a separate packaging
* decision). The protocol logic (`handleMessage`) is PURE and fully testable;
* `runServer` is a thin line-delimited-JSON loop over injectable streams.
*
* Bin entry / packaging / manifest-version-sync is slice 3b — this module is
* the additive, importable server surface a host (or the bin shim) drives.
*/
'use strict';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import commandRoutingHub = require('./command-routing-hub.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateIo = require('./state-io.cjs');
export const PROTOCOL_VERSION = '2024-11-05';
export const SERVER_NAME = 'gsd-core';
const SERVER_VERSION = '1.7.0';
// JSON-RPC 2.0 error codes.
const PARSE_ERROR = -32700;
const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const INTERNAL_ERROR = -32603;
export interface McpContext {
cwd?: string;
}
export interface JsonRpcRequest {
jsonrpc?: string;
id?: unknown;
method?: string;
params?: unknown;
}
const TOOLS = [
{
name: 'gsd_invoke_command',
description: 'Invoke a GSD command via the command-routing hub (interface point 1).',
inputSchema: {
type: 'object',
properties: {
family: { type: 'string', description: 'Command family (e.g. "query", "state", "phase").' },
subcommand: { type: 'string', description: 'Subcommand name.' },
args: { type: 'array', items: {}, description: 'Positional args.' },
},
required: ['family', 'subcommand'],
},
},
{
name: 'gsd_read_state',
description: 'Read a .planning state file (interface point 5).',
inputSchema: {
type: 'object',
properties: { path: { type: 'string', description: 'Absolute path under .planning/.' } },
required: ['path'],
},
},
{
name: 'gsd_write_state',
description: 'Write a .planning state file (interface point 5).',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Absolute path under .planning/.' },
content: { type: 'string', description: 'File content.' },
},
required: ['path', 'content'],
},
},
];
function errorResponse(id: unknown, code: number, message: string, data?: unknown) {
const err: { code: number; message: string; data?: unknown } = { code, message };
if (data !== undefined) err.data = data;
return { jsonrpc: '2.0', id, error: err };
}
function okResponse(id: unknown, result: unknown) {
return { jsonrpc: '2.0', id, result };
}
function asString(v: unknown): string | null {
return typeof v === 'string' ? v : null;
}
function callTool(name: string, args: unknown, ctx: McpContext): { content: Array<{ type: string; text: string }>; isError?: boolean } {
const a = (args && typeof args === 'object' ? args : {}) as Record<string, unknown>;
const cwd = asString(ctx.cwd) || process.cwd();
try {
if (name === 'gsd_invoke_command') {
const family = asString(a.family);
const subcommand = asString(a.subcommand);
if (!family || !subcommand) {
return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "family" and "subcommand".' }] };
}
const hub = commandRoutingHub.createHub();
const res = hub.dispatch({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd, raw: undefined });
return { content: [{ type: 'text', text: JSON.stringify(res) }] };
}
if (name === 'gsd_read_state') {
const p = asString(a.path);
if (!p) return { isError: true, content: [{ type: 'text', text: 'gsd_read_state requires string "path".' }] };
const io = stateIo.createStateIO({ io: 'filesystem' });
return { content: [{ type: 'text', text: io.read(p) }] };
}
if (name === 'gsd_write_state') {
const p = asString(a.path);
const content = asString(a.content);
if (!p || content === null) return { isError: true, content: [{ type: 'text', text: 'gsd_write_state requires string "path" and "content".' }] };
const io = stateIo.createStateIO({ io: 'filesystem' });
io.write(p, content);
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, path: p }) }] };
}
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
} catch (e) {
return { isError: true, content: [{ type: 'text', text: `Tool error: ${e instanceof Error ? e.message : String(e)}` }] };
}
}
/**
* Pure JSON-RPC handler. Takes a parsed request object + context, returns a
* JSON-RPC response object (or null for JSON-RPC notifications — no id).
*/
export function handleMessage(request: JsonRpcRequest, ctx: McpContext = {}): Record<string, unknown> | null {
if (!request || typeof request !== 'object') {
return errorResponse(null, INVALID_REQUEST, 'Invalid Request: not an object.');
}
const id = request.id;
// Notification (no id) → no response per JSON-RPC.
const isNotification = id === undefined || id === null;
const method = typeof request.method === 'string' ? request.method : '';
let result: unknown;
switch (method) {
case 'initialize':
result = {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
};
break;
case 'tools/list':
result = { tools: TOOLS };
break;
case 'tools/call': {
const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>;
const toolName = asString(params.name);
if (!toolName) return errorResponse(id, INVALID_PARAMS, 'tools/call requires string "name".');
result = callTool(toolName, params.arguments, ctx);
break;
}
default:
if (isNotification) return null;
return errorResponse(id, METHOD_NOT_FOUND, `Method not found: ${method || '(empty)'}.`);
}
if (isNotification) return null;
return okResponse(id, result);
}
/**
* Thin stdio loop over injectable streams. Reads line-delimited JSON-RPC from
* `input`, writes responses (one JSON object + newline) to `output`. Stops when
* input ends. Errors in handleMessage are caught and emitted as JSON-RPC error
* responses (the loop never crashes).
*/
export async function runServer({
input,
output,
ctx = {},
}: {
input: NodeJS.ReadableStream;
output: NodeJS.WritableStream;
ctx?: McpContext;
}): Promise<void> {
for await (const chunk of input as AsyncIterable<Buffer>) {
const lines = chunk.toString('utf-8').split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
output.write(JSON.stringify(errorResponse(null, PARSE_ERROR, 'Parse error.')) + '\n');
continue;
}
try {
const response = handleMessage(parsed as JsonRpcRequest, ctx);
if (response) output.write(JSON.stringify(response) + '\n');
} catch (e) {
output.write(JSON.stringify(errorResponse(null, INTERNAL_ERROR, e instanceof Error ? e.message : 'Internal error.')) + '\n');
}
}
}
}
// handleMessage + runServer are exported above (export function); PROTOCOL_VERSION
// + SERVER_NAME are exported above (export const).

78
src/model-adapter.cts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Model adapter seam (ADR-1239 Phase C-1, AC3 / #1680).
*
* Two model-layer adapters selected by the negotiated `modelMode` axis
* (host-integration.cts):
*
* - `passive` — GSD can only inject prompts / a per-agent `model` field (the
* CLI runtimes: claude/gemini/codex/opencode/cursor/…). Formalizes today's
* tier routing from src/model-resolver.cts: `resolveModel` delegates
* straight to `resolveModelForTier`, so passive reproduces current behavior
* byte-for-behavior.
* - `active` — the host exposes a provider `sendRequest` (VS Code `vscode.lm`,
* pi providers). GSD calls the model through the host. Ships here as a SEAM:
* a host-supplied `sendRequest` slot, fail-closed until a real consumer
* binds it (Phase 5 / #1682).
*
* Minimal (per ADR-1239 open wire-shape question): one factory, two shapes
* discriminated by `mode`. Concrete provider protocol (request/response shape)
* is fixed when a real active host lands in Phase 5.
*/
'use strict';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelResolver = require('./model-resolver.cjs');
export type ModelMode = 'passive' | 'active';
export interface ModelAdapter {
readonly mode: ModelMode;
}
export interface PassiveModelAdapter extends ModelAdapter {
readonly mode: 'passive';
/** Resolve a model id for a tier. Delegates to model-resolver's tier routing. */
resolveModel(args: { cwd: string; agentType: string; attempt?: number }): string;
}
export interface ActiveModelAdapter extends ModelAdapter {
readonly mode: 'active';
/** Host-supplied model-call primitive. Throws (fail-closed) if not bound. */
sendRequest(req: unknown): unknown;
}
export interface CreateModelAdapterOptions {
/** Required for `active`: the host's model-call primitive. Ignored for `passive`. */
sendRequest?: (req: unknown) => unknown;
}
export function createModelAdapter(
{ modelMode }: { modelMode: ModelMode },
options: CreateModelAdapterOptions = {},
): ModelAdapter {
if (modelMode !== 'passive' && modelMode !== 'active') {
throw new TypeError(`createModelAdapter: modelMode must be 'passive' | 'active' (got ${JSON.stringify(modelMode)})`);
}
if (modelMode === 'passive') {
return Object.freeze({
mode: 'passive' as const,
resolveModel({ cwd, agentType, attempt }: { cwd: string; agentType: string; attempt?: number }): string {
return modelResolver.resolveModelForTier(cwd, agentType, attempt);
},
});
}
// active: bind the host's sendRequest, fail-closed if absent.
const sendRequest = options.sendRequest;
return Object.freeze({
mode: 'active' as const,
sendRequest(req: unknown): unknown {
if (typeof sendRequest !== 'function') {
throw new Error(
'ActiveModelAdapter.sendRequest: no host provider bound — the active model seam ' +
'requires a sendRequest primitive from the host (Phase 5 wires a concrete provider).',
);
}
return sendRequest(req);
},
});
}

View File

@@ -1648,7 +1648,16 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
if (isLastPhase && roadmapContent !== null) {
try {
const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
// #1591: match BOTH heading-style phases (`### Phase N:`) AND
// checkbox-list items (`- [ ] Phase N:` / `- [x] Phase N:`). When
// the active milestone's checklist is `- [ ]` items inside a
// <details> block (and the next phase has no directory yet, so the
// disk-based resolver finds nothing), this roadmap-enumeration
// fallback is the only path that can find the next phase. The prior
// heading-only pattern missed checkbox items → is_last_phase=true on
// a mid-milestone phase. The marker alternation is the only change;
// the number/name captures are unchanged.
const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let pm: RegExpExecArray | null;
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
if (comparePhaseNum(pm[1], phaseNum) > 0) {

View File

@@ -489,6 +489,140 @@ export function dispositionForProhibition(
};
}
/* ─────────────────────────────────────────────────────────────────────────────
* Honest verifier (#1154) — the truth-axis abstention disposition.
*
* The verify-time MIRROR of the prohibition judgment-tier (ADR-550 D4), applied to the edge
* `backstop` truth tier (D7a). The edge probe already CLASSIFIES a non-inferable check as
* `verification: 'backstop'` and plan-phase lifts it into `must_haves.truths`. This gives that tier
* the same abstain-and-flag disposition D4 gave prohibitions: a `backstop` truth the verifier cannot
* confirm with explicit evidence disposes UNVERIFIED+flagged → `human_needed` (reason
* `insufficient_spec`), NEVER a silent green. An inferable (explicit/plain) truth never abstains (the
* over-abstention guard, AC#3). Exogenous, not endogenous: the trigger is the external `backstop`
* tag, not the verifier's self-judgment (ADR-550 Lineage / N17 — confidence-gating cannot reach a
* blind spot the model does not perceive).
* ───────────────────────────────────────────────────────────────────────────── */
/** The truth verification tier — the SAME orthogonal axis the edge adapter uses (ADR-550 D7a). */
export type TruthVerification = 'explicit' | 'backstop';
/**
* A `must_haves.truths` item is EITHER a plain string (an inferable truth — today's shape and the
* overwhelmingly common case) OR a flat-scalar object carrying the non-inferable marker. Object form
* is additive and default-absent (Hyrum's Law): a reader that only ever sees strings behaves
* byte-identically. New readers MUST normalize via `truthStatement`/`truthVerification`.
*/
export type TruthItem = string | { statement: string; verification?: TruthVerification | null };
/** Extract a truth's statement text from either the string or the object form (the Hyrum normalizer). */
export function truthStatement(truth: unknown): string {
if (typeof truth === 'string') return truth;
if (truth != null && typeof truth === 'object') {
const s = (truth as { statement?: unknown }).statement;
if (typeof s === 'string') return s;
}
return '';
}
/**
* Extract a truth's verification tier, or `null` when it carries none (a plain string, or an object
* with no/garbled marker). Failing toward `null` is the Postel-safe direction: an unrecognized marker
* grades NORMALLY (never a spurious abstention — the over-abstention guard, AC#3), and the marker is
* machine-emitted from validated edge data so garbling is not a live input path.
*/
export function truthVerification(truth: unknown): TruthVerification | null {
if (truth == null || typeof truth !== 'object') return null;
const v = (truth as { verification?: unknown }).verification;
return v === 'explicit' || v === 'backstop' ? v : null;
}
/**
* Conservative serializer (Postel: "send well-formed, minimal data") for projecting truths into a
* `must_haves.truths` block — the truth-axis analogue of `projectProhibitions`. A `backstop` truth is
* emitted as a flat-scalar object `{ statement, verification: 'backstop' }` (ADR-550 #1278: flat
* scalars round-trip the existing `parseMustHavesBlock`; a nested object would mangle it). Every other
* truth collapses to a bare statement string — only the non-inferable tier needs a structured marker,
* so an `explicit`/inferable truth never carries one (no spurious markers). Empty statements are dropped.
*/
export function projectTruths(
items: unknown,
): Array<string | { statement: string; verification: 'backstop' }> {
if (!Array.isArray(items)) return [];
const out: Array<string | { statement: string; verification: 'backstop' }> = [];
for (const item of items) {
const statement = truthStatement(item);
if (!statement) continue;
if (truthVerification(item) === 'backstop') {
out.push({ statement, verification: 'backstop' });
} else {
out.push(statement);
}
}
return out;
}
/**
* The structured verify-time disposition of a single truth. Same shape as `ProhibitionDisposition`
* (status the verifier reads + `flagged` for SUMMARY surfacing + `tier` echo + human-readable
* `reason`), but `reason` carries the STABLE token `insufficient_spec` on abstention so the
* `human_needed` outcome is distinguishable from an ordinary manual-UAT `human_needed` (review
* condition-1 caveat).
*/
export interface TruthDisposition {
status: 'green' | 'unverified';
flagged: boolean;
tier: TruthVerification | null;
reason: string;
}
/** Optional context: evidence that a `backstop` truth IS confirmable (a passing wired held-out/PBT test, or a directly-observed behavior). */
export interface TruthDispositionContext {
evidence?: unknown[];
}
/** The stable, distinguishable verdict-reason token for an abstained non-inferable truth (review condition 1). */
export const INSUFFICIENT_SPEC = 'insufficient_spec';
/**
* Deterministic verify-time disposition for a single truth (ADR-550 D4 truth-axis mirror, #1154).
* PURE — no LLM judgment (ADR-550 D5); the LLM verifier's only job is to decide whether `evidence`
* exists, this helper owns the routing once that is known.
*
* - A `backstop` (non-inferable) truth with NO explicit evidence → `{ unverified, flagged }`,
* reason `insufficient_spec`. NEVER green — the verify-time companion to D4's never-silent-pass.
* - A `backstop` truth WITH explicit evidence (a passing wired held-out/property test) → `green`.
* Abstention is for the *unconfirmable*, not for every non-inferable check.
* - Any non-`backstop` truth (explicit, or a plain inferable string) → `green`, never flagged.
* This is the over-abstention guard (AC#3): abstention fires ONLY on the exogenous backstop tag.
*/
export function dispositionForUnverifiableTruth(
truth: unknown,
context: TruthDispositionContext = {},
): TruthDisposition {
const tier = truthVerification(truth);
// Over-abstention guard (AC#3): only a backstop (non-inferable) truth is ever a candidate to abstain.
if (tier !== 'backstop') {
return {
status: 'green',
flagged: false,
tier,
reason: 'inferable truth — verified normally (no abstention; ADR-550 D4 over-abstention guard)',
};
}
const evidence = Array.isArray(context.evidence) ? context.evidence : [];
if (evidence.length === 0) {
// ABSTAIN: a non-inferable truth the verifier cannot confirm with explicit evidence. Routes to
// human_needed with the distinguishable insufficient_spec reason — never a silent pass (ADR-550 D4).
return { status: 'unverified', flagged: true, tier, reason: INSUFFICIENT_SPEC };
}
return {
status: 'green',
flagged: false,
tier,
reason: 'backstop truth confirmed by explicit evidence (a passing wired held-out/property test or directly-observed behavior)',
};
}
/*
* CLI scaffold (the EP-06 invokable surface, generalized). Each probe ships one bin that
* calls `runProbeCli` with its own `analyze` (closing over the adapter's propose + validators)

View File

@@ -78,8 +78,11 @@ function coerceTruthToString(t: unknown): string {
return String(t);
}
if (typeof t === 'object') {
// Prefer common title-bearing keys produced by parseMustHavesBlock
for (const k of ['title', 'text', 'name', 'rule', 'path', 'provides']) {
// Prefer common title-bearing keys produced by parseMustHavesBlock. `statement` is the canonical
// truth/prohibition payload field — and the carrier of #1154's object-form backstop truth
// `{ statement, verification: backstop }`, so it leads (a non-inferable truth must be coerced by
// its statement, never dropped — the Hyrum backward-compat guard for the new marker).
for (const k of ['statement', 'title', 'text', 'name', 'rule', 'path', 'provides']) {
const v = (t as Record<string, unknown>)[k];
if (typeof v === 'string' && v.trim()) return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);

View File

@@ -257,3 +257,51 @@ export function getGlobalConfigHomeFragment(runtime: string): string {
const frag = GLOBAL_CONFIG_HOME_FRAGMENTS[runtime];
return typeof frag === 'string' && frag.length > 0 ? frag : DEFAULT_CONFIG_HOME_FRAGMENT;
}
/**
* The runtime ids for which `bin/install.js` needs an `is<Runtime>` boolean
* predicate (every installed host that takes a non-claude install branch).
* Single source of truth — adding a runtime is one entry here, not a per-
* function declaration block (the add-a-host tax ADR-1239 Phase B / #1679 AC2
* removes).
*/
const RUNTIME_FLAG_IDS = Object.freeze([
'opencode', 'kilo', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi',
] as const);
/**
* Return a frozen map of `is<Runtime>` boolean predicates for the given runtime
* id (e.g. `flags.isOpencode`). Collapses the four duplicated `const isX =
* runtime === 'x'` declaration blocks that lived in `bin/install.js`'s
* `uninstall`/`writeManifest`/`install`/etc. into one helper (sibling to
* `getDirName`/`getRuntimeLabel`). Pure: no I/O.
*/
export function runtimeFlags(runtime: string): Readonly<Record<string, boolean>> {
const flags: Record<string, boolean> = {};
for (const id of RUNTIME_FLAG_IDS) {
flags['is' + id.charAt(0).toUpperCase() + id.slice(1)] = runtime === id;
}
return Object.freeze(flags);
}
/**
* The `/gsd-new-project` invocation syntax per runtime — the post-install
* "next step" command string. Most runtimes use the default `/gsd-new-project`;
* a few hosts need a different surface syntax. Collapses the 14-line
* `if (runtime === 'x') command = ...` chain in bin/install.js's next-step
* message (ADR-1239 Phase B / #1679 AC2). Pure: no I/O.
*/
const DEFAULT_NEW_PROJECT_COMMAND = '/gsd-new-project';
const RUNTIME_NEW_PROJECT_COMMANDS: Readonly<Record<string, string>> = {
gemini: '/gsd:new-project',
codex: '$gsd-new-project',
cursor: 'gsd-new-project (mention the skill name)',
kimi: '/skill:gsd-new-project',
};
export function getRuntimeNewProjectCommand(runtime: string): string {
if (!runtime) return DEFAULT_NEW_PROJECT_COMMAND;
const c = RUNTIME_NEW_PROJECT_COMMANDS[runtime];
return typeof c === 'string' && c.length > 0 ? c : DEFAULT_NEW_PROJECT_COMMAND;
}

View File

@@ -50,6 +50,7 @@ interface StateModule {
cmdStateValidate(cwd: string, raw: boolean): void;
cmdStateSync(cwd: string, opts: { verify: string | boolean | null | undefined }, raw: boolean): void;
cmdStatePrune(cwd: string, opts: { keepRecent: string; dryRun: boolean }, raw: boolean): void;
cmdStateRebuild(cwd: string, opts: { dryRun: boolean; verbose: boolean }, raw: boolean): void;
cmdStateCompletePhase(cwd: string, raw: boolean, phase: string | null | undefined): void;
cmdStateMilestoneSwitch(cwd: string, milestone: string | null | undefined, name: string | null | undefined, raw: boolean): void;
}
@@ -190,6 +191,10 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
const a = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
},
rebuild: () => {
const a = parseNamedArgs(args, [], ['dry-run', 'verbose']);
state.cmdStateRebuild(cwd, { dryRun: a['dry-run'] === true, verbose: a['verbose'] === true }, raw);
},
// complete-phase: CJS-only — no SDK counterpart.
'complete-phase': () => {
const a = parseNamedArgs(args, ['phase']);

75
src/state-io.cts Normal file
View File

@@ -0,0 +1,75 @@
/**
* State IO seam (ADR-1239 Phase C-1, AC4 / #1680).
*
* Abstracts `.planning/` + config IO behind the negotiated `stateIO` axis
* (host-integration.cts):
*
* - `filesystem` — most hosts: reads/writes under `.planning/` +
* `configHome`. TODAY's behavior. Delegates to fs.
* - `sandboxed-storage` — VS Code web (no arbitrary FS). Seam: a
* host-supplied backend; fail-closed until Phase 5.
* - `session-log-append` — pi (JSONL session log). Seam: host-supplied
* backend; fail-closed until Phase 5.
*
* `filesystem` is the default and reproduces today's IO byte-for-behavior
* (planning-workspace.cts keeps routing its fs ops; this seam is the
* abstraction a non-filesystem host swaps in). `configHome` write-confinement
* (ADR-1239 Phase B / #1679) applies to the filesystem path.
*
* Minimal seam: the host-backend protocol is fixed when a real non-filesystem
* host lands (Phase 5 / #1682).
*/
'use strict';
import fs from 'node:fs';
export type StateIOMode = 'filesystem' | 'sandboxed-storage' | 'session-log-append';
export interface StateIOAdapter {
readonly io: StateIOMode;
read(path: string): string;
write(path: string, content: string): void;
}
export interface StateIOBackend {
read(path: string): string;
write(path: string, content: string): void;
}
export interface CreateStateIOOptions {
/** Required for non-filesystem modes: the host's storage backend. */
backend?: StateIOBackend;
}
export function createStateIO(
{ io }: { io: StateIOMode },
options: CreateStateIOOptions = {},
): StateIOAdapter {
if (io !== 'filesystem' && io !== 'sandboxed-storage' && io !== 'session-log-append') {
throw new TypeError(`createStateIO: io must be 'filesystem' | 'sandboxed-storage' | 'session-log-append' (got ${JSON.stringify(io)})`);
}
if (io === 'filesystem') {
// Today's behavior — straight fs. planning-workspace.cts keeps its routing;
// this is the swap-point a non-filesystem host replaces.
return Object.freeze({
io: 'filesystem',
read(path: string) { return fs.readFileSync(path, 'utf-8'); },
write(path: string, content: string) { fs.writeFileSync(path, content, 'utf-8'); },
});
}
// sandboxed-storage / session-log-append: host backend, fail-closed until bound.
const backend = options.backend;
const unbound = (): never => {
throw new Error(
`${io} stateIO: no host backend bound — non-filesystem state requires a backend (Phase 5 wires the concrete host).`,
);
};
if (!backend || typeof backend.read !== 'function' || typeof backend.write !== 'function') {
return Object.freeze({ io, read: unbound, write: unbound });
}
return Object.freeze({
io,
read(path: string) { return backend.read(path); },
write(path: string, content: string) { backend.write(path, content); },
});
}

View File

@@ -263,6 +263,26 @@ export type StateTransitionDeps = {
* pure and testable without disk I/O.
*/
roadmapProvider?: () => string | null;
/**
* Phase-inventory provider for `rebuild` (ADR-1817 §2): re-derives the
* `## By-Phase Progress` table from canonical disk sources. Returns one
* record per on-disk phase directory under `.planning/phases/`. Optional:
* when absent, `rebuild` skips table reconciliation (Leaky-Abstractions
* guard — the core stays pure and testable without disk I/O).
*/
phaseInventoryProvider?: () => PhaseInventoryRecord[] | null;
};
/**
* One on-disk phase record (ADR-1817). The `rebuild` transition consumes
* these to re-derive the `## By-Phase Progress` table; the adapter wires
* this to the same disk scan `buildStateFrontmatter` uses.
*/
export type PhaseInventoryRecord = {
number: string;
name: string;
planCount: number;
summaryCount: number;
};
export type StateTransitionIntent =
@@ -301,8 +321,12 @@ export type StateTransitionIntent =
totalPlansInPhase: number | null;
/** Recomputed progress percent (0-100), or null when it must be left untouched (#1761). */
percent: number | null;
}
| {
kind: 'rebuild';
};
// Phase 7 closes out the discriminated union (all 10 lifecycle/maintenance intents).
// Phase 7 closed the union for the 10 ADR-1769 intents. ADR-1817 adds `rebuild`
// as the 11th capstone transition (body-structure derivability contract).
export type StateTransitionResult = {
content: string;
@@ -351,6 +375,8 @@ export function transitionCore(
return pruneCore(content, intent);
case 'sync':
return syncCore(content, intent, deps);
case 'rebuild':
return rebuildCore(content, intent, deps);
}
}
@@ -1521,3 +1547,433 @@ function syncCore(
return { content: modified, updated, data: { changes } };
}
// ----------------------------------------------------------------------------
// rebuild — intent implementation (ADR-1817, capstone 11th transition)
// ----------------------------------------------------------------------------
//
// Implements the body-structure derivability contract (ADR-1817 §2–§6):
// - §2 re-derives derived sections (## Current Position prose, By Phase table
// inside ## Performance Metrics), preserves curated sections verbatim
// (## Accumulated Context, ## Deferred Items, ## Project Reference, ##
// Session Continuity's prose fields) and unknown sections.
// - §3 every mutation appends a structured entry to ## Rebuild Log
// (ADR-1411 provenance principle — never drop silently).
// - §4 idempotency: a no-mutation rebuild appends NO log entry, so two
// successive runs on a clean file are byte-identical.
// - §5 non-overlapping with sync (sync = 3 frontmatter fields, lightweight,
// auto-triggered; rebuild = body structure, heavier, manual).
// - §6 orthogonal to auto_prune_state (rebuild reconciles with current
// canonical sources; prune removes by retention policy).
//
// Section ordering is invariant: rebuild rewrites content IN PLACE; it does
// not reorder, insert (other than ## Rebuild Log when absent), or remove
// sections.
const REBUILD_LOG_SECTION = '## Rebuild Log';
const REBUILD_LOG_TRUNCATION_LIMIT = 512;
type RebuildLogEntryKind =
| 'placeholder-removed'
| 'current-position-reconciled'
| 'by-phase-table-reconciled'
| 'session-archive-deduplicated';
interface RebuildLogEntry {
timestamp: string;
kind: RebuildLogEntryKind;
section: string;
before: string;
after: string;
reason: string;
}
/**
* Truncate a string for inclusion in a rebuild log entry. Per ADR-1817 §3 the
* `before` / `after` fields are bounded to REBUILD_LOG_TRUNCATION_LIMIT chars
* to prevent unbounded log growth when the drifted content is large.
*/
function truncateForLog(s: string): string {
if (s.length <= REBUILD_LOG_TRUNCATION_LIMIT) return s;
return s.slice(0, REBUILD_LOG_TRUNCATION_LIMIT - 3) + '...';
}
/**
* Apply a `rebuild` transition to STATE.md content. Pure core per ADR-1769 §3
* and ADR-1817 §1. Returns `{ content, updated, data }` where `data.mutated`
* is false when no drift was found (idempotency contract, ADR-1817 §4).
*/
function rebuildCore(
content: string,
_intent: { kind: 'rebuild' },
deps: StateTransitionDeps,
): StateTransitionResult {
const timestamp = deps.clock.nowIso();
const log: RebuildLogEntry[] = [];
let modified = content;
// §2 Decision: re-derive derived sections, preserve others. Order is
// oldest-section-first so log entries appear in body order.
modified = reconcileCurrentPosition(modified, timestamp, log);
modified = reconcileByPhaseTable(modified, deps, timestamp, log);
modified = stripTemplatePlaceholders(modified, timestamp, log);
modified = deduplicateSessionArchive(modified, timestamp, log);
// §3 + §4: append the audit log ONLY when mutations occurred. The
// log-appends-only-on-mutation rule is what makes idempotency byte-identical
// (without it, the second invocation would always append a no-op entry).
if (log.length > 0) {
modified = appendRebuildLogSection(modified, log);
}
const updated = log.length > 0 ? ['rebuild'] : [];
return {
content: modified,
updated,
data: {
mutated: log.length > 0,
mutations: log.length,
log,
},
};
}
/**
* §2 — re-derive `## Current Position` prose fields from frontmatter.
*
* Drift class: `Phase:`, `Status:` etc. in body contradict frontmatter after
* a milestone switch or prune (epic #1817). The body prose is re-derivable
* because `buildStateFrontmatter` already derives the canonical values from
* disk; rebuild pushes those back into the body prose.
*
* Implementation: pull each canonical value from frontmatter and replace the
* body field via `stateReplaceField`. Skip silently when frontmatter lacks
* the key (Leaky-Abstractions guard — don't synthesize values the canonical
* source doesn't have).
*/
function reconcileCurrentPosition(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
const fm = extractFrontmatter(content) as Record<string, unknown>;
if (!fm || typeof fm !== 'object') return content;
let modified = content;
// Phase prose: frontmatter `current_phase` overrides body `**Current Phase:**`.
// The body `Phase:` prose line (e.g. "Phase: 3 of 12 (Test Phase)") is owned
// by other transitions (beginPhase / completePhase) and reconstructed from
// total-phase counts; rebuild reconciles only the `**Current Phase:**` body
// field that frontmatter is the canonical source for.
const fmPhase = fm.current_phase;
if (typeof fmPhase === 'string' || typeof fmPhase === 'number') {
const canonicalPhase = String(fmPhase);
const existing = stateExtractField(modified, 'Current Phase');
if (existing !== null && existing !== canonicalPhase) {
const replaced = stateReplaceField(modified, 'Current Phase', canonicalPhase);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalPhase),
reason: "frontmatter 'current_phase' is canonical; body 'Current Phase' was stale",
});
}
}
}
// Phase name prose.
const fmPhaseName = fm.current_phase_name;
if (typeof fmPhaseName === 'string' || typeof fmPhaseName === 'number') {
const canonicalName = String(fmPhaseName);
const existing = stateExtractField(modified, 'Current Phase Name');
if (existing !== null && existing !== canonicalName) {
const replaced = stateReplaceField(modified, 'Current Phase Name', canonicalName);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalName),
reason: "frontmatter 'current_phase_name' is canonical; body 'Current Phase Name' was stale",
});
}
}
}
return modified;
}
/**
* §2 — re-derive the `**By Phase:**` table inside `## Performance Metrics`
* from the injected `phaseInventoryProvider`. Drift class: orphaned rows for
* phases from a prior milestone, or zero-padded phase IDs that were renamed
* (epic #1817).
*
* Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is
* absent (no disk scan wired), this step is a no-op. The core stays pure and
* testable without disk I/O.
*/
function reconcileByPhaseTable(
content: string,
deps: StateTransitionDeps,
timestamp: string,
log: RebuildLogEntry[],
): string {
if (!deps.phaseInventoryProvider) return content;
const inventory = deps.phaseInventoryProvider();
if (!inventory || inventory.length === 0) return content;
// The canonical table shape (from gsd-core/templates/state.md):
// | Phase | Plans | Total | Avg/Plan |
// |-------|-------|-------|----------|
// | - | - | - | - |
// rebuild renders one row per inventory record (Phase N: P plans). The
// Total/Avg columns are runtime-collected by other commands; rebuild does
// NOT re-derive them and resets them to '-' so future plan-completion
// repopulates. The canonical reconciliation target is the row SET.
const tableRows = inventory.map((r) => `| ${r.number} | ${r.planCount} | - | - |`);
const canonicalTable = [
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
...tableRows,
];
// Line-based splice: find `**By Phase:**` line, then walk forward collecting
// the table block (header + separator + body rows), replace the block with
// the canonical table preceded by a single blank-line separator.
const lines = content.split('\n');
const markerIdx = lines.findIndex((l) => l.trim() === '**By Phase:**');
if (markerIdx === -1) return content; // unknown shape — preserve verbatim
// Walk forward from markerIdx+1 to find the table block span. Skip leading
// blank lines; once we see the first table row, consume subsequent table
// rows; stop at the first non-table line after we've started.
let blockStart = -1;
let blockEnd = -1;
for (let i = markerIdx + 1; i < lines.length; i++) {
const trimmed = lines[i].trim();
const isTable = trimmed.startsWith('|') && trimmed.endsWith('|');
if (blockStart === -1) {
if (isTable) { blockStart = i; blockEnd = i + 1; }
else if (trimmed === '') continue;
else break; // non-table, non-blank before any row — unknown shape
} else {
if (isTable) blockEnd = i + 1;
else break;
}
}
if (blockStart === -1) return content; // no table found
// Replace lines[blockStart..blockEnd) with canonicalTable.
const beforeBlock = lines.slice(0, markerIdx + 1);
const afterBlock = lines.slice(blockEnd);
// Splice: `**By Phase:**` + blank + canonicalTable rows + (whatever came after)
const newLines = [...beforeBlock, '', ...canonicalTable, ...afterBlock];
const candidate = newLines.join('\n');
if (candidate === content) return content;
log.push({
timestamp,
kind: 'by-phase-table-reconciled',
section: STATE_MD_SECTIONS.performanceMetrics,
before: truncateForLog(lines.slice(blockStart, blockEnd).join('\n')),
after: truncateForLog(canonicalTable.join('\n')),
reason: 'phase dirs on disk are canonical; rows for missing phases dropped, missing phases added',
});
return candidate;
}
/**
* §2 + epic-#1817 drift class — template-placeholder field values left in
* place when an AI agent wrote partial state. The canonical template uses
* `[X]`, `[Y]`, `[Phase name]`, `[date]`, `[N]`, etc. (see
* `gsd-core/templates/state.md`). Rebuild clears any `**Field:** [placeholder]`
* line where the value still matches the placeholder shape.
*
* "Clears" means: leaves the field in place with the literal text `(pending)`,
* signalling that rebuild recognized the placeholder but had no canonical
* source to substitute. This is honest — better than silently leaving `[X]`
* which looks like a value.
*/
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
function stripTemplatePlaceholders(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
// Scan body `**Field:** value` lines; when value matches the placeholder
// shape, replace with `(pending)`. We deliberately do NOT touch fields that
// other transitions actively maintain (syncCore's three, beginPhase's set,
// etc.) — only the template placeholder rows that nothing has touched.
const lines = content.split('\n');
const replacements: Array<{ lineIdx: number; before: string; after: string; fieldName: string }> = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(/^\s*\*\*([^*]+):\*\*\s*(.*)$/);
if (!m) continue;
const fieldName = m[1];
const value = m[2];
if (TEMPLATE_PLACEHOLDER_VALUE.test(value)) {
const cleared = `**${fieldName}:** (pending)`;
replacements.push({ lineIdx: i, before: line, after: cleared, fieldName });
}
}
if (replacements.length === 0) return content;
for (const r of replacements) {
lines[r.lineIdx] = r.after;
log.push({
timestamp,
kind: 'placeholder-removed',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(r.before.trim()),
after: truncateForLog(r.after),
reason: `field ${JSON.stringify(r.fieldName)} still carried template placeholder ${JSON.stringify(r.before.match(/\*\*[^*]+:\*\*\s*(.*)$/)?.[1]?.trim() ?? '')}; no canonical source available — replaced with (pending)`,
});
}
return lines.join('\n');
}
/**
* §2 + epic-#1817 drift class — duplicate `## Session Continuity Archive`
* blocks from repeated `state record-session` calls on a corrupt file. The
* canonical template has one `## Session Continuity` section; archived blocks
* may accumulate as `### Session — <timestamp>` H3 sub-sections under it.
* Rebuild keeps the most-recent N (default 3) and drops older duplicates,
* logging each drop.
*
* Conservative scope: only acts when the section has more than 3 H3
* `### Session —` sub-headings; otherwise it's a no-op (preserve verbatim).
*/
const DEFAULT_MAX_SESSION_ARCHIVES = 3;
// `tokenizeHeadings` strips leading `#` markers — `h.text` for `### Session — X`
// is just `Session — X`. Match the bare heading text.
const SESSION_ARCHIVE_H3 = /^Session\s+—/;
function deduplicateSessionArchive(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
const hs = tokenizeHeadings(content);
// Find `## Session Continuity` H2.
const sectionIdx = hs.findIndex((h) => h.level === 2 && h.text === 'Session Continuity');
if (sectionIdx === -1) return content;
// Find the section span: from this H2's offset to the next H2 (or EOF).
const sectionStart = hs[sectionIdx].offset;
let sectionEnd = content.length;
for (let i = sectionIdx + 1; i < hs.length; i++) {
if (hs[i].level === 2) { sectionEnd = hs[i].offset; break; }
}
// Count `### Session — …` H3 sub-headings inside the section.
const archiveHeadings = hs.filter(
(h) => h.level === 3 && h.offset >= sectionStart && h.offset < sectionEnd && SESSION_ARCHIVE_H3.test(h.text),
);
if (archiveHeadings.length <= DEFAULT_MAX_SESSION_ARCHIVES) return content;
// Keep the most-recent N by offset (last N in document order; if timestamps
// in the H3 text are in chronological order — the template convention —
// last-N == most-recent-N).
const dropCount = archiveHeadings.length - DEFAULT_MAX_SESSION_ARCHIVES;
const toDrop = archiveHeadings.slice(0, dropCount);
// Compute the byte spans to drop: each archived H3 spans from its offset to
// the next H3 (or to sectionEnd). Drop with one preceding blank line so we
// don't leave a dangling separator.
let mutated = content;
// Process from the bottom up so offsets don't shift mid-edit.
for (let i = toDrop.length - 1; i >= 0; i--) {
const h = toDrop[i];
let spanEnd = sectionEnd;
// Find next H3 at-or-after h.offset (within the section).
for (const candidate of hs) {
if (candidate.level === 3 && candidate.offset > h.offset && candidate.offset < sectionEnd) {
spanEnd = candidate.offset;
break;
}
}
const dropStart = h.offset;
const before = mutated.slice(0, dropStart);
const after = mutated.slice(spanEnd);
const droppedText = mutated.slice(dropStart, spanEnd);
mutated = before + after;
log.push({
timestamp,
kind: 'session-archive-deduplicated',
section: STATE_MD_SECTIONS.sessionContinuity,
before: truncateForLog(droppedText),
after: '',
reason: `archived session ${JSON.stringify(h.text)} exceeded the ${DEFAULT_MAX_SESSION_ARCHIVES}-most-recent retention; dropped`,
});
}
return mutated;
}
/**
* §3 — append a structured audit entry to `## Rebuild Log`. Per ADR-1817 §3
* the section is created if absent; existing entries are preserved verbatim
* (append-only).
*
* Format (yaml-ish, human-readable, machine-parseable):
*
* ## Rebuild Log
*
* - timestamp: 2026-06-29T19:30:00Z
* kind: placeholder-removed
* section: ## Current Position
* before: ...
* after: ...
* reason: ...
*/
function appendRebuildLogSection(content: string, entries: RebuildLogEntry[]): string {
const lines = content.split('\n');
// Render the new entry block.
const rendered: string[] = [];
for (const e of entries) {
rendered.push(`- timestamp: ${e.timestamp}`);
rendered.push(` kind: ${e.kind}`);
rendered.push(` section: ${e.section}`);
rendered.push(` before: ${e.before.replace(/\n/g, ' \\n ')}`);
rendered.push(` after: ${e.after.replace(/\n/g, ' \\n ')}`);
rendered.push(` reason: ${e.reason.replace(/\n/g, ' \\n ')}`);
}
// Locate an existing `## Rebuild Log` section.
const sectionHeaderIdx = lines.findIndex((l) => l.trim() === REBUILD_LOG_SECTION);
if (sectionHeaderIdx === -1) {
// Create the section at end-of-file, separated by a blank line.
const needsLeadingBlank = lines.length > 0 && lines[lines.length - 1].trim() !== '';
const trailer = needsLeadingBlank ? ['', REBUILD_LOG_SECTION, '', ...rendered] : [REBUILD_LOG_SECTION, '', ...rendered];
return [...lines, ...trailer].join('\n');
}
// Append to the existing section. Find the end of the existing log entries
// (walk forward until the next H2 or EOF). Insert before that boundary.
let insertAt = sectionHeaderIdx + 1;
while (insertAt < lines.length) {
const l = lines[insertAt];
if (/^##\s/.test(l)) break;
insertAt++;
}
// Preserve a blank-line separator before the new entries if the prior line
// is non-blank and non-header.
const sep: string[] = [];
if (insertAt > 0 && lines[insertAt - 1].trim() !== '' && lines[insertAt - 1].trim() !== REBUILD_LOG_SECTION) {
sep.push('');
}
const next = [...lines.slice(0, insertAt), ...sep, ...rendered, ...lines.slice(insertAt)];
return next.join('\n');
}

View File

@@ -35,6 +35,7 @@ import stateTransitionMod = require('./state-transition.cjs');
const { transitionCore, applyStatePreservation } = stateTransitionMod;
type StateTransitionIntent = stateTransitionMod.StateTransitionIntent;
type StateTransitionDeps = stateTransitionMod.StateTransitionDeps;
type PhaseInventoryRecord = stateTransitionMod.PhaseInventoryRecord;
import {
computeProgressPercent,
normalizeProgressNumbers,
@@ -109,6 +110,12 @@ interface StatePruneOptions {
silent?: boolean;
}
interface StateRebuildOptions {
dryRun?: boolean;
verbose?: boolean;
silent?: boolean;
}
interface StateSyncOptions {
verify?: boolean;
}
@@ -144,6 +151,7 @@ const _diskScanCache = new Map<string, {
completedPhases: number;
totalPlans: number;
completedPlans: number;
milestoneBounded: boolean;
}>();
// Track all lock files held by this process so they can be removed on exit.
@@ -1359,6 +1367,9 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
let completedPhases: number | null = null;
let totalPlans: number | null = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
let completedPlans: number | null = null;
// #1761 read-path: set from cached.milestoneBounded inside the disk-scan
// block; consumed at the percent computation to mirror the cmdStateSync guard.
let milestoneUnbounded = false;
if (cwd) {
try {
@@ -1373,10 +1384,11 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
// exclusion (#1514). Computed before the disk scan so retired phases
// can be dropped from the dir set too.
let roadmapScope: string | null = null;
let roadmapRaw: string | null = null;
let retiredPhaseNums = new Set<string>();
try {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapRaw = platformReadSync(roadmapPath);
roadmapRaw = platformReadSync(roadmapPath);
if (roadmapRaw !== null) {
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
@@ -1449,20 +1461,39 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
}
}
cached = {
totalPhases: roadmapPhaseCount > 0
? Math.max(phaseDirs.length, roadmapPhaseCount)
: phaseDirs.length,
completedPhases: diskCompletedPhases,
totalPlans: diskTotalPlans,
completedPlans: diskTotalSummaries,
};
cached = (() => {
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
// asserted milestone version can't be bounded to a versioned ROADMAP
// heading, extractCurrentMilestone falls back to the whole document
// and roadmapPhaseCount conflates sibling milestones. In that case
// don't substitute the whole-doc count — fall back to the on-disk
// phase-dir count only, and mark unbounded so percent is skipped
// downstream (mirrors the sync write-path guard).
let milestoneBounded = true;
if (milestone && roadmapRaw !== null) {
const versionedHeading = new RegExp(
`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`,
'mi',
);
milestoneBounded = versionedHeading.test(roadmapRaw);
}
return {
totalPhases: (!milestoneBounded || roadmapPhaseCount === 0)
? phaseDirs.length
: Math.max(phaseDirs.length, roadmapPhaseCount),
milestoneBounded,
completedPhases: diskCompletedPhases,
totalPlans: diskTotalPlans,
completedPlans: diskTotalSummaries,
};
})();
_diskScanCache.set(cwd, cached);
}
totalPhases = cached.totalPhases;
completedPhases = cached.completedPhases;
totalPlans = cached.totalPlans;
completedPlans = cached.completedPlans;
milestoneUnbounded = cached.milestoneBounded === false;
}
} catch { /* intentionally empty */ }
}
@@ -1473,7 +1504,10 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
// instead of a false 100% from plan-only coverage (#3242 Bug B).
// Falls back to the body Progress: field only when no plan files exist on disk.
let progressPercent = computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases);
if (progressPercent === null && progressRaw) {
// #1761 read-path: when the milestone can't be bounded, percent would be
// derived from a conflated/understated total — skip it (mirror cmdStateSync).
if (milestoneUnbounded) progressPercent = null;
if (progressPercent === null && progressRaw && !milestoneUnbounded) {
const pctMatch = progressRaw.match(/(\d+)%/);
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
}
@@ -2544,6 +2578,113 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v
}, raw, totalPruned > 0 ? 'true' : 'false');
}
/**
* Rebuild STATE.md body structure from canonical sources (ADR-1817).
*
* Implements the `gsd state rebuild` subcommand (issue #1817 Phase 2, #1826).
* Wires the pure `rebuildCore` transition (Phase 1, #1827) to the CLI:
* - Locks via `readModifyWriteStateMd` (real path) or reads-only (dry-run).
* - Wires `phaseInventoryProvider` to a real `.planning/phases/` disk scan.
* - `--dry-run`: computes the rebuild, emits a structured diff, writes nothing.
* - `--verbose`: emits the audit-log entries to stderr (in addition to the
* `## Rebuild Log` section that `rebuildCore` already appends to STATE.md).
*
* Per ADR-1817 §5 this is the heavy/manual counterpart to the lightweight,
* auto-triggered `state sync` (3 frontmatter fields). The two compose
* non-overlappingly.
*/
function cmdStateRebuild(cwd: string, options: StateRebuildOptions, raw: boolean): void {
const silent = !!options.silent;
const emit = silent ? () => {} : (result: Record<string, unknown>, r: boolean, v?: string) => output(result, r, v);
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
const dryRun = !!options.dryRun;
const verbose = !!options.verbose;
// Wire phaseInventoryProvider to a real `.planning/phases/` disk scan. This
// is the same canonical source `buildStateFrontmatter` consults; the Leaky-
// Abstractions guard in `rebuildCore` (ADR-1817 §1) keeps the pure core
// testable without this dep — here we provide it.
const phaseInventoryProvider = (): PhaseInventoryRecord[] | null => {
try {
const phasesDir = path.join(planningPaths(cwd).planning, 'phases');
if (!fs.existsSync(phasesDir) || !fs.statSync(phasesDir).isDirectory()) return null;
const entries = fs.readdirSync(phasesDir);
const records: PhaseInventoryRecord[] = [];
for (const entry of entries) {
const full = path.join(phasesDir, entry);
let stat: fs.Stats;
try { stat = fs.statSync(full); } catch { continue; }
if (!stat.isDirectory()) continue;
// Directory-name convention: `<NN>-<slug>` (e.g. `03-test-phase`).
const m = entry.match(/^(\d+)-(.+)$/);
if (!m) continue;
const files = fs.readdirSync(full);
const planCount = files.filter(f => /-PLAN\.md$/i.test(f)).length;
const summaryCount = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
records.push({ number: m[1], name: m[2], planCount, summaryCount });
}
return records;
} catch {
return null;
}
};
const deps: StateTransitionDeps = {
progressProvider: () => null,
clock: realClock,
phaseInventoryProvider,
};
const runRebuild = (content: string) => transitionCore(content, { kind: 'rebuild' }, deps);
const emitVerboseLog = (log: unknown): void => {
if (!verbose || !Array.isArray(log)) return;
for (const entry of log) {
// Treat user-data as data-only (ADR-1577 untrusted-input-boundary).
process.stderr.write(`[rebuild] ${JSON.stringify(entry)}\n`);
}
};
if (dryRun) {
const content = fs.readFileSync(statePath, 'utf-8');
const result = runRebuild(content);
const data = (result.data ?? {}) as { log?: unknown[]; mutated?: boolean };
emitVerboseLog(data.log);
const mutated = data.mutated === true;
emit({
rebuilt: false,
dry_run: true,
mutations: Array.isArray(data.log) ? data.log.length : 0,
mutated,
note: mutated ? 'Run without --dry-run to apply changes' : 'Nothing to rebuild',
}, raw, mutated ? 'true' : 'false');
return;
}
// Real path: lock + RMW via the existing seam. The rebuild log is captured
// so we can emit it to stderr under --verbose (the section is also written
// to STATE.md by rebuildCore itself, per ADR-1817 §3).
let capturedLog: unknown[] = [];
let capturedMutated = false;
readModifyWriteStateMd(statePath, (content: string) => {
const result = runRebuild(content);
const data = (result.data ?? {}) as { log?: unknown[]; mutated?: boolean };
capturedLog = Array.isArray(data.log) ? data.log : [];
capturedMutated = data.mutated === true;
return result.content;
}, cwd);
emitVerboseLog(capturedLog);
emit({
rebuilt: capturedMutated,
mutations: capturedLog.length,
note: capturedMutated ? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail' : 'Nothing to rebuild',
}, raw, capturedMutated ? 'true' : 'false');
}
/**
* Mark the current phase as COMPLETE in STATE.md.
* Updates Status, Last Activity, and the Current Position section to reflect
@@ -2722,6 +2863,7 @@ export = {
cmdStateValidate,
cmdStateSync,
cmdStatePrune,
cmdStateRebuild,
cmdStateMilestoneSwitch,
cmdSignalWaiting,
cmdSignalResume,

View File

@@ -32,5 +32,5 @@
"gsd-ui-checker.md": 11088,
"gsd-ui-researcher.md": 19332,
"gsd-user-profiler.md": 8516,
"gsd-verifier.md": 48859
"gsd-verifier.md": 49124
}

View File

@@ -87,3 +87,116 @@ describe('#1761: state sync leaves Progress untouched when milestone is unbounde
`Progress must be left untouched when the milestone is unbounded; before=${JSON.stringify(before)} after=${JSON.stringify(after)} (#1761)`);
});
});
// #1761 read-path: the ADR-1769 Phase 7 fix (#1794) closed the `state sync`
// WRITE path, but `state json` (the READ path) rebuilds progress via
// buildStateFrontmatter, whose roadmapPhaseCount loop counts phase headings
// across the WHOLE document when extractCurrentMilestone can't bound the
// asserted milestone. Result: state json reported a conflated total_phases
// (sum of sibling milestones) + a derived percent, contradicting the sync
// guard. This block mirrors the write-path guard on the read path.
describe('#1761 read-path: state json does not conflate progress when milestone is unbounded', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('state json omits percent and does NOT report the conflated whole-doc total_phases', () => {
// Repro from the issue: STATE.md asserts milestone: v2.0; ROADMAP has two
// UNVERSIONED sibling milestones (4 + 4 phases) — neither matches v2.0, so
// the milestone is unbounded. One summarized phase dir on disk.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'milestone: v2.0',
'milestone_name: Second',
'current_phase: "2"',
'status: executing',
'---',
'',
'# GSD State',
'**Current Phase:** 2',
'**Status:** Executing Phase 2',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# ROADMAP',
'## Milestone 1: First Milestone',
'### Phase 1: a',
'### Phase 2: b',
'### Phase 3: c',
'### Phase 4: d',
'## Milestone 2: Second Milestone',
'### Phase 5: e',
'### Phase 6: f',
'### Phase 7: g',
'### Phase 8: h',
'',
].join('\n'));
// One summarized phase dir on disk.
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
fs.mkdirSync(dir01, { recursive: true });
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools('state json --raw', tmpDir);
assert.ok(result.success, `state json failed: ${result.error}`);
const out = JSON.parse(result.output);
// BEFORE the fix this printed progress.total_phases: 8 (4+4 sibling
// milestones) and percent: 13 — exactly the conflated read-path the sync
// guard was added to prevent.
assert.ok(
out.progress === undefined || out.progress.percent === undefined,
`state json must omit percent when the milestone is unbounded; got progress=${JSON.stringify(out.progress)}`,
);
assert.ok(
!(out.progress && out.progress.total_phases === 8),
`state json must NOT report the conflated whole-doc total_phases (8 = 4+4 sibling milestones); got total_phases=${out.progress && out.progress.total_phases}`,
);
});
test('state json still reports percent + total_phases when the milestone IS bounded (versioned ROADMAP)', () => {
// Control: a versioned ROADMAP heading matching the asserted milestone
// keeps the read path unchanged — the guard only fires when unbounded.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: First',
'current_phase: "1"',
'status: executing',
'---',
'',
'# GSD State',
'**Current Phase:** 1',
'**Status:** Executing Phase 1',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# ROADMAP',
'## Milestone 1: First Milestone v1.0',
'### Phase 1: a',
'### Phase 2: b',
'',
].join('\n'));
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
fs.mkdirSync(dir01, { recursive: true });
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools('state json --raw', tmpDir);
assert.ok(result.success, `state json failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(
out.progress && typeof out.progress.percent === 'number',
`state json must report a numeric percent when the milestone is bounded; got progress=${JSON.stringify(out.progress)}`,
);
assert.strictEqual(
out.progress.total_phases,
2,
'bounded read path must report the versioned milestone phase count (2)',
);
});
});

View File

@@ -9,6 +9,9 @@
* #2535 — sub_repos and plan_checker legacy keys need CONFIG_KEY_SUGGESTIONS migration hints
* #3162 — resolve_model_ids missing from VALID_CONFIG_KEYS; workflow._auto_chain_active must be
* accepted by isValidConfigKey (written by workflows) without being user-visible
* #1747 — buildNewProjectConfig emits four search-provider keys (tavily_search, ref_search,
* perplexity, jina) that research-provider.cts consumes but were missing from
* VALID_CONFIG_KEYS, causing /gsd-settings unknown-key warnings on fresh projects
*/
const { describe, test } = require('node:test');
@@ -74,6 +77,48 @@ describe('VALID_CONFIG_KEYS correctness', () => {
});
});
describe('#1747: new-project config emits only schema-recognized provider keys', () => {
// buildNewProjectConfig emits seven search-provider availability flags and
// research-provider.cts providerAvailability() consumes all seven, but only
// three were in VALID_CONFIG_KEYS → /gsd-settings warned on the four
// unregistered keys (tavily_search, ref_search, perplexity, jina) for every
// freshly generated .planning/config.json.
// The four keys that were emitted + consumed but missing from the schema.
const MISSING_KEYS = ['tavily_search', 'ref_search', 'perplexity', 'jina'];
// Every config-driven provider flag read by providerAvailability() in
// src/research-provider.cts. context7/websearch are excluded: hardcoded
// `true`, not config-gated, so no config key to register.
const PROVIDER_CONFIG_KEYS = [
'brave_search',
'firecrawl',
'exa_search',
'tavily_search',
'ref_search',
'perplexity',
'jina',
];
test('the four previously-missing provider keys are in VALID_CONFIG_KEYS', () => {
const absent = MISSING_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k));
assert.deepStrictEqual(
absent,
[],
`These provider keys are emitted by buildNewProjectConfig and consumed by research-provider.cts but missing from VALID_CONFIG_KEYS:\n ${absent.join('\n ')}\n\nAdd them to gsd-core/bin/shared/config-schema.manifest.json (validKeys).`
);
});
test('every config-driven research-provider flag is registered in the schema (drift guard)', () => {
const drifted = PROVIDER_CONFIG_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k));
assert.deepStrictEqual(
drifted,
[],
`These research-provider config flags are not in VALID_CONFIG_KEYS — a fresh /gsd-new-project config would trigger an unknown-key warning under /gsd-settings:\n ${drifted.join('\n ')}\n\nWhen you add a provider to providerAvailability() in src/research-provider.cts, also register its config key in gsd-core/bin/shared/config-schema.manifest.json.`
);
});
});
describe('ADR-857 Phase 6 capability config ownership', () => {
test('migrated capability config keys are valid through the registry, not central schema residue', () => {
const capabilityKeys = Object.keys(capabilityRegistry.configSchema || {}).sort();

View File

@@ -133,6 +133,34 @@ Plans:
assert.ok(roadmap.includes(sharedTruth), 'shared truth listed');
});
test('#1154: surfaces a cross-cutting backstop (object-form) truth by its statement, not dropped', () => {
// An object-form backstop truth `{ statement, verification: backstop }` (the #1154 non-inferable
// marker on must_haves.truths) shared across 2 plans must be coerced by its `statement` — the
// Hyrum backward-compat guard: a truth-reader must tolerate the new object form, never drop it.
const backstopTruth = 'statement: Adjacent touching intervals merge\n verification: backstop';
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [backstopTruth, 'DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [backstopTruth, 'API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 1, 'the shared backstop truth is surfaced, not dropped');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Adjacent touching intervals merge'), 'surfaced by its statement text, not [object Object]');
});
test('does not surface constraints that appear in only one plan', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap

View File

@@ -0,0 +1,84 @@
'use strict';
/**
* Tests for the external-descriptor trust gate (ADR-1239 Phase C-2, #1681).
* Pins: confined passes; escapes (.. / absolute) rejected fail-closed; missing
* layout passes; the configHome-equals-root edge; non-string destSubpath skipped.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
isPathConfined,
assertDescriptorConfined,
} = require('../gsd-core/bin/lib/external-descriptor-trust.cjs');
test('isPathConfined: confined paths are true, escapes are false', () => {
const root = path.join('/home', 'me', '.gsd');
assert.ok(isPathConfined('skills', root), 'simple subdir is confined');
assert.ok(isPathConfined('skills/gsd-plan.md', root), 'nested subdir is confined');
assert.ok(isPathConfined('.', root), 'root itself is confined');
assert.ok(!isPathConfined('../etc/passwd', root), 'parent escape is NOT confined');
assert.ok(!isPathConfined('../../etc', root), 'multi-level escape is NOT confined');
assert.ok(!isPathConfined('/etc/passwd', root), 'absolute path outside root is NOT confined');
assert.ok(!isPathConfined('', root), 'empty target is NOT confined');
assert.ok(!isPathConfined('skills', ''), 'empty root is NOT confined');
});
test('assertDescriptorConfined: a benign descriptor (all destSubpaths under configHome) passes', () => {
const desc = {
id: 'community-host',
runtime: { artifactLayout: {
global: [{ destSubpath: 'skills' }, { destSubpath: 'agents' }],
local: [{ destSubpath: 'commands' }],
} },
};
assert.doesNotThrow(() => assertDescriptorConfined(desc, '/home/me/.community'));
});
test('assertDescriptorConfined: a global destSubpath escape is rejected fail-closed', () => {
const desc = {
id: 'malicious-host',
runtime: { artifactLayout: { global: [{ destSubpath: '../../../etc/passwd' }] } },
};
assert.throws(
() => assertDescriptorConfined(desc, '/home/me/.gsd'),
/malicious-host.*unconfined global destSubpath.*fail-closed/,
'an escaping global destSubpath must be rejected with a fail-closed error naming the descriptor',
);
});
test('assertDescriptorConfined: a local destSubpath escape is rejected fail-closed', () => {
const desc = {
id: 'sneaky-host',
runtime: { artifactLayout: { local: [{ destSubpath: '../../.ssh/authorized_keys' }] } },
};
assert.throws(
() => assertDescriptorConfined(desc, '/home/me/.gsd'),
/sneaky-host.*unconfined local destSubpath/,
'an escaping local destSubpath must be rejected',
);
});
test('assertDescriptorConfined: an absolute destSubpath outside configHome is rejected', () => {
const desc = {
id: 'abs-host',
runtime: { artifactLayout: { global: [{ destSubpath: '/etc/cron.d/evil' }] } },
};
assert.throws(() => assertDescriptorConfined(desc, '/home/me/.gsd'), /unconfined global destSubpath/);
});
test('assertDescriptorConfined: a descriptor with no artifact layout passes (nothing to confine)', () => {
assert.doesNotThrow(() => assertDescriptorConfined({ id: 'bare', runtime: {} }, '/home/me/.gsd'));
assert.doesNotThrow(() => assertDescriptorConfined({ id: 'noruntime' }, '/home/me/.gsd'));
assert.doesNotThrow(() => assertDescriptorConfined({}, '/home/me/.gsd'));
assert.doesNotThrow(() => assertDescriptorConfined(null, '/home/me/.gsd'));
});
test('assertDescriptorConfined: non-string / empty destSubpath entries are skipped (not flagged)', () => {
const desc = {
id: 'mixed',
runtime: { artifactLayout: { global: [{ destSubpath: 'skills' }, { destSubpath: '' }, { destSubpath: null }, {}, { destSubpath: 'agents' }] } },
};
assert.doesNotThrow(() => assertDescriptorConfined(desc, '/home/me/.x'), 'valid entries pass; invalid entries skipped');
});

View File

@@ -0,0 +1,75 @@
'use strict';
/**
* Integration test: loadRegistry wires the external-descriptor trust gate
* (ADR-1239 Phase C-2 / #1681 slice 2). When `configHome` is supplied, an
* installed overlay whose declared destSubpath escapes it is rejected
* (skip + confinement reason) and NOT composed; a confined overlay composes.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { loadRegistry } = require('../gsd-core/bin/lib/capability-loader.cjs');
const HOST = '1.6.0';
function featureCap(id, extra) {
return {
id, role: 'feature', version: '1.0.0', title: id, description: 'overlay cap',
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
...extra,
};
}
// Build a temp GSD home with .gsd/capabilities/<id>/capability.json per cap.
function makeOverlayHome(caps) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-trust-'));
for (const cap of caps) {
const dir = path.join(home, '.gsd', 'capabilities', cap.id);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(cap), 'utf8');
}
return home;
}
test('loadRegistry configHome confinement: escaping overlay is skipped with a confinement reason', () => {
const home = makeOverlayHome([
featureCap('confined-host', { runtime: { artifactLayout: { global: [{ destSubpath: 'skills' }] } } }),
featureCap('escape-host', { runtime: { artifactLayout: { global: [{ destSubpath: '../../../etc/passwd' }] } } }),
]);
try {
const reg = loadRegistry({
includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST,
configHome: path.join(home, '.target'),
});
const overlayIds = Object.keys(reg.capabilities || {}).filter((id) => id === 'confined-host' || id === 'escape-host');
assert.ok(overlayIds.includes('confined-host'), 'confined overlay must be composed');
assert.ok(!overlayIds.includes('escape-host'), 'escaping overlay must NOT be composed');
const skips = (reg._overlay && reg._overlay.warnings) || [];
const confinementSkip = skips.find((s) => /confinement/.test(s.reason || ''));
assert.ok(confinementSkip, `an overlay must be skipped with a confinement reason; warnings=${JSON.stringify(skips)}`);
assert.match(confinementSkip.reason, /escape-host/, 'the confinement skip must name the escaping descriptor');
} finally {
cleanup(home);
}
});
test('loadRegistry configHome confinement: omitted configHome = no load-time check (backward-compatible; relies on install-time gate)', () => {
// Same escaping overlay, but no configHome passed → it is NOT rejected by the load-time gate.
const home = makeOverlayHome([
featureCap('escape-host', { runtime: { artifactLayout: { global: [{ destSubpath: '../../../etc' }] } } }),
]);
try {
const reg = loadRegistry({ includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST });
const warnings = (reg._overlay && reg._overlay.warnings) || [];
const confinementSkip = warnings.find((s) => /confinement/.test(s.reason || ''));
assert.ok(!confinementSkip, 'no configHome → no load-time confinement check (backward-compatible)');
} finally {
cleanup(home);
}
});

View File

@@ -34,14 +34,13 @@
"agents/gsd-ui-checker.md": "dbbe694a26265473",
"agents/gsd-ui-researcher.md": "8c7e91c85e7099f5",
"agents/gsd-user-profiler.md": "25d65f6458454764",
"agents/gsd-verifier.md": "2a64590bb09e21e6",
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"agents/gsd-verifier.md": "5902e27c7091f4b1",
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4",
"gsd-core/bin/verify-reapply-patches.cjs": "8bc541aabc2e143c",
@@ -86,6 +85,7 @@
"gsd-core/references/gates.md": "7dc9fd3a3d6217c6",
"gsd-core/references/git-integration.md": "9e6076a137f9e156",
"gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7",
"gsd-core/references/honest-verifier.md": "a31a4bf42d82d5e9",
"gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f",
"gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987",
"gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf",
@@ -263,7 +263,7 @@
"gsd-core/workflows/note.md": "3ce09c0aa0a20599",
"gsd-core/workflows/pause-work.md": "3196d681d4dd8c71",
"gsd-core/workflows/plan-milestone-gaps.md": "94b193dfc9ca3681",
"gsd-core/workflows/plan-phase.md": "d99fb8159a2db1a9",
"gsd-core/workflows/plan-phase.md": "fde84f67730f7131",
"gsd-core/workflows/plan-review-convergence.md": "b1623082557cdca5",
"gsd-core/workflows/plant-seed.md": "1fb45cd49f66f572",
"gsd-core/workflows/pr-branch.md": "c8fd9fa250cb39fd",
@@ -289,7 +289,7 @@
"gsd-core/workflows/spike.md": "0f9a81bcf4573195",
"gsd-core/workflows/stats.md": "a20eb078d2ab11be",
"gsd-core/workflows/sync-skills.md": "8326a7ff0411b077",
"gsd-core/workflows/thread.md": "03a527a71b8fab12",
"gsd-core/workflows/thread.md": "3fb6b552e45fedbd",
"gsd-core/workflows/transition.md": "a7a5fe4040084308",
"gsd-core/workflows/ui-phase.md": "652785fbba26e80c",
"gsd-core/workflows/ui-review.md": "816b2bde136157f9",
@@ -297,26 +297,26 @@
"gsd-core/workflows/undo.md": "6ab639d1fc7e0721",
"gsd-core/workflows/update.md": "dc93f366e2156e37",
"gsd-core/workflows/validate-phase.md": "5bac28c71d21c740",
"gsd-core/workflows/verify-phase.md": "1c6a2e1128966675",
"gsd-core/workflows/verify-phase.md": "e7059c04c816e62d",
"gsd-core/workflows/verify-work.md": "dd7f78f947b86976",
"hooks/gsd-check-update-worker.js": "668c24ea284ff623",
"hooks/gsd-check-update.js": "7e42f76b2bcdd764",
"hooks/gsd-config-reload.js": "17bf778d432b3d2a",
"hooks/gsd-context-monitor.js": "ead852d2b4ddb92a",
"hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c",
"hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00",
"hooks/gsd-ensure-canonical-path.js": "83e02e841e123037",
"hooks/gsd-graphify-update.sh": "396ebda3c6705dc9",
"hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9",
"hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d",
"hooks/gsd-read-guard.js": "b602f88f046a7551",
"hooks/gsd-read-injection-scanner.js": "d17d30e2b1a42582",
"hooks/gsd-session-state.sh": "b1496e6a5204a6df",
"hooks/gsd-statusline.js": "c3ceac8122b2c3ed",
"hooks/gsd-update-banner.js": "74817c820b7a4ec1",
"hooks/gsd-validate-commit.sh": "14d3d966c74dc310",
"hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6",
"hooks/gsd-worktree-path-guard.js": "25969c741edaf032",
"hooks/gsd-check-update-worker.js": "fa301e6366270d5f",
"hooks/gsd-check-update.js": "4617a98bf529e4c3",
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
"hooks/gsd-context-monitor.js": "6d81d7326e5b2710",
"hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a",
"hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375",
"hooks/gsd-ensure-canonical-path.js": "64d092d7e4a01211",
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
"hooks/gsd-prompt-guard.js": "a749b8cb2c5248de",
"hooks/gsd-read-guard.js": "9e423cd03e2d1b16",
"hooks/gsd-read-injection-scanner.js": "eefea61f9b0e464c",
"hooks/gsd-session-state.sh": "e54379ba86bf1b6d",
"hooks/gsd-statusline.js": "8ae31be7a006204b",
"hooks/gsd-update-banner.js": "55143a25f978f301",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
"hooks/gsd-worktree-path-guard.js": "838498aa91619740",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "45b2431992d3d7d2",

View File

@@ -34,7 +34,7 @@
"agents/gsd-ui-checker.md": "4cf947a98db6410e",
"agents/gsd-ui-researcher.md": "3f8646572e9c3ec1",
"agents/gsd-user-profiler.md": "622220df0654b6bf",
"agents/gsd-verifier.md": "b1108277a4e858e3",
"agents/gsd-verifier.md": "4046b8d4ca23e342",
"commands/gsd-add-tests.md": "3608d0cf4b515103",
"commands/gsd-ai-integration-phase.md": "70843d4904743f7e",
"commands/gsd-audit-fix.md": "1b805946362c4f19",
@@ -104,13 +104,12 @@
"commands/gsd-verify-work.md": "1cb62ea69b117acb",
"commands/gsd-workspace.md": "dd1bc09d2b768e0b",
"commands/gsd-workstreams.md": "52ab9c585d3a00f3",
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
@@ -155,6 +154,7 @@
"gsd-core/references/gates.md": "7dc9fd3a3d6217c6",
"gsd-core/references/git-integration.md": "77bf9dff38b2c9d4",
"gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7",
"gsd-core/references/honest-verifier.md": "8815c9fc18c35719",
"gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f",
"gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987",
"gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf",
@@ -332,7 +332,7 @@
"gsd-core/workflows/note.md": "5a99eb396c744619",
"gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b",
"gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25",
"gsd-core/workflows/plan-phase.md": "fe6b786141eab878",
"gsd-core/workflows/plan-phase.md": "5e3c949ca65cd672",
"gsd-core/workflows/plan-review-convergence.md": "10007f8382864bcd",
"gsd-core/workflows/plant-seed.md": "7b795d7a1b4c9f06",
"gsd-core/workflows/pr-branch.md": "f2a35833fe784a53",
@@ -358,7 +358,7 @@
"gsd-core/workflows/spike.md": "53127654e77256bf",
"gsd-core/workflows/stats.md": "01c24349370a0e6d",
"gsd-core/workflows/sync-skills.md": "b505e6f8331c0918",
"gsd-core/workflows/thread.md": "c26ca43fdf928d46",
"gsd-core/workflows/thread.md": "6d075b5d26500e9d",
"gsd-core/workflows/transition.md": "eee3435817fab185",
"gsd-core/workflows/ui-phase.md": "e81783508b8b6819",
"gsd-core/workflows/ui-review.md": "1ad3654435000881",
@@ -366,26 +366,26 @@
"gsd-core/workflows/undo.md": "96d2775f008b3a85",
"gsd-core/workflows/update.md": "231e7c305b40c417",
"gsd-core/workflows/validate-phase.md": "50f37b705b6e44fb",
"gsd-core/workflows/verify-phase.md": "968d569ea4ef377f",
"gsd-core/workflows/verify-phase.md": "7579af6cd757f644",
"gsd-core/workflows/verify-work.md": "6f9c666386cb6d7e",
"hooks/gsd-check-update-worker.js": "e42be7414a05e99d",
"hooks/gsd-check-update.js": "b3333951b2091807",
"hooks/gsd-config-reload.js": "17bf778d432b3d2a",
"hooks/gsd-context-monitor.js": "11e88809e2cdc331",
"hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c",
"hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00",
"hooks/gsd-ensure-canonical-path.js": "3499b6e6b453dc59",
"hooks/gsd-graphify-update.sh": "396ebda3c6705dc9",
"hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9",
"hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d",
"hooks/gsd-read-guard.js": "b602f88f046a7551",
"hooks/gsd-read-injection-scanner.js": "ca99873d0bf8b4ba",
"hooks/gsd-session-state.sh": "b1496e6a5204a6df",
"hooks/gsd-statusline.js": "9b7005c36891671d",
"hooks/gsd-update-banner.js": "74817c820b7a4ec1",
"hooks/gsd-validate-commit.sh": "14d3d966c74dc310",
"hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6",
"hooks/gsd-worktree-path-guard.js": "7921523b20372a1e",
"hooks/gsd-check-update-worker.js": "cc1ef5f840f9dfc9",
"hooks/gsd-check-update.js": "7b3a7983d5f1f5d3",
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
"hooks/gsd-context-monitor.js": "44ff1bbf292747af",
"hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a",
"hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375",
"hooks/gsd-ensure-canonical-path.js": "d569f5f3578e93e5",
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
"hooks/gsd-prompt-guard.js": "a749b8cb2c5248de",
"hooks/gsd-read-guard.js": "9e423cd03e2d1b16",
"hooks/gsd-read-injection-scanner.js": "c8800819f7443a15",
"hooks/gsd-session-state.sh": "e54379ba86bf1b6d",
"hooks/gsd-statusline.js": "3be32d2012c77fc1",
"hooks/gsd-update-banner.js": "55143a25f978f301",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
"hooks/gsd-worktree-path-guard.js": "65b934c3a1709e89",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "f46a329fcfefa465",

View File

@@ -33,14 +33,13 @@
"agents/gsd-ui-checker.md": "dd06843892f6b0c8",
"agents/gsd-ui-researcher.md": "85d7d6cc36388435",
"agents/gsd-user-profiler.md": "003276f85792cfda",
"agents/gsd-verifier.md": "0a0c618959bb00d2",
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"agents/gsd-verifier.md": "76bcf41aa9fd9b53",
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
@@ -85,6 +84,7 @@
"gsd-core/references/gates.md": "7dc9fd3a3d6217c6",
"gsd-core/references/git-integration.md": "5c70ef3203b7c9ce",
"gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7",
"gsd-core/references/honest-verifier.md": "8815c9fc18c35719",
"gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f",
"gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987",
"gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf",
@@ -262,7 +262,7 @@
"gsd-core/workflows/note.md": "42b66686b2c102cb",
"gsd-core/workflows/pause-work.md": "0be71264eafd16dc",
"gsd-core/workflows/plan-milestone-gaps.md": "1976bf2001969719",
"gsd-core/workflows/plan-phase.md": "bce0904c3d3b7d59",
"gsd-core/workflows/plan-phase.md": "b0373c2198f4acf7",
"gsd-core/workflows/plan-review-convergence.md": "414df04b7ddff73c",
"gsd-core/workflows/plant-seed.md": "936a848f1d6c409e",
"gsd-core/workflows/pr-branch.md": "c8827e8a15426bf5",
@@ -288,7 +288,7 @@
"gsd-core/workflows/spike.md": "aae8bcad15642645",
"gsd-core/workflows/stats.md": "17b4f2059f4b4ef2",
"gsd-core/workflows/sync-skills.md": "b505e6f8331c0918",
"gsd-core/workflows/thread.md": "75df5cc71f72c33d",
"gsd-core/workflows/thread.md": "5ad0e0d5ce7e0d11",
"gsd-core/workflows/transition.md": "96ce39403ca69594",
"gsd-core/workflows/ui-phase.md": "790e5982e5b715c3",
"gsd-core/workflows/ui-review.md": "51945fda8e931f99",
@@ -296,26 +296,26 @@
"gsd-core/workflows/undo.md": "791e0bf96d9a057f",
"gsd-core/workflows/update.md": "0b389258dcc09332",
"gsd-core/workflows/validate-phase.md": "2aa540f2c2479501",
"gsd-core/workflows/verify-phase.md": "452968b6becb18a1",
"gsd-core/workflows/verify-phase.md": "15a999f82868ad29",
"gsd-core/workflows/verify-work.md": "d2e8f5d5f2b8f050",
"hooks/gsd-check-update-worker.js": "f0c2b5b7169642ba",
"hooks/gsd-check-update.js": "a0e4882e66670e4d",
"hooks/gsd-config-reload.js": "17bf778d432b3d2a",
"hooks/gsd-context-monitor.js": "fbe88dd134dc7156",
"hooks/gsd-cursor-post-tool.js": "dd1b12f795de8d72",
"hooks/gsd-cursor-session-start.js": "a93095ac609a3ea6",
"hooks/gsd-ensure-canonical-path.js": "34f4522a23cc5f41",
"hooks/gsd-graphify-update.sh": "396ebda3c6705dc9",
"hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9",
"hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d",
"hooks/gsd-read-guard.js": "b602f88f046a7551",
"hooks/gsd-read-injection-scanner.js": "e149870bbd213882",
"hooks/gsd-session-state.sh": "b1496e6a5204a6df",
"hooks/gsd-statusline.js": "38cb2dd48cc03294",
"hooks/gsd-update-banner.js": "d3228b9e674296b4",
"hooks/gsd-validate-commit.sh": "14d3d966c74dc310",
"hooks/gsd-workflow-guard.js": "6faa8f81812a8b5d",
"hooks/gsd-worktree-path-guard.js": "5c2ebabb9d21b42a",
"hooks/gsd-check-update-worker.js": "a530efdb5fdc0da3",
"hooks/gsd-check-update.js": "25cde66a12d6b886",
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
"hooks/gsd-context-monitor.js": "ecbe9747e4a442e0",
"hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71",
"hooks/gsd-cursor-session-start.js": "05a14e903c5edafa",
"hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16",
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
"hooks/gsd-prompt-guard.js": "a749b8cb2c5248de",
"hooks/gsd-read-guard.js": "9e423cd03e2d1b16",
"hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f",
"hooks/gsd-session-state.sh": "e54379ba86bf1b6d",
"hooks/gsd-statusline.js": "7c315416ffc99a9a",
"hooks/gsd-update-banner.js": "b457746cb76c1957",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
"hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "ea876b1ec185173e",

View File

@@ -37,14 +37,13 @@
"agents/gsd-ui-checker.md": "35a6b14813aa03ff",
"agents/gsd-ui-researcher.md": "878c7d0e82fa861a",
"agents/gsd-user-profiler.md": "622220df0654b6bf",
"agents/gsd-verifier.md": "64cc793b5f0110bc",
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"agents/gsd-verifier.md": "0a437cf3ed90693f",
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
"gsd-core/bin/shared/config-schema.manifest.json": "7d398e94c44e5268",
"gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
@@ -89,6 +88,7 @@
"gsd-core/references/gates.md": "7dc9fd3a3d6217c6",
"gsd-core/references/git-integration.md": "f5403e1470e46e69",
"gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7",
"gsd-core/references/honest-verifier.md": "8815c9fc18c35719",
"gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f",
"gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987",
"gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf",
@@ -266,7 +266,7 @@
"gsd-core/workflows/note.md": "5a99eb396c744619",
"gsd-core/workflows/pause-work.md": "9c1acf8c30a244fd",
"gsd-core/workflows/plan-milestone-gaps.md": "95ca791b0867fe2d",
"gsd-core/workflows/plan-phase.md": "2716d6636e37ce6a",
"gsd-core/workflows/plan-phase.md": "ab9ed0b5acfe7d8a",
"gsd-core/workflows/plan-review-convergence.md": "2cdba6216184aac4",
"gsd-core/workflows/plant-seed.md": "d0d63f83ae7c939b",
"gsd-core/workflows/pr-branch.md": "15ccec6bd303ea46",
@@ -292,7 +292,7 @@
"gsd-core/workflows/spike.md": "204e742c846ee0d9",
"gsd-core/workflows/stats.md": "5cfea82b894eee3c",
"gsd-core/workflows/sync-skills.md": "b505e6f8331c0918",
"gsd-core/workflows/thread.md": "5ae4c3141bdedd88",
"gsd-core/workflows/thread.md": "4a009fd2cc4387a7",
"gsd-core/workflows/transition.md": "fe82e77df8dceb1b",
"gsd-core/workflows/ui-phase.md": "06f1f80de620a319",
"gsd-core/workflows/ui-review.md": "0af83311f5e41f48",
@@ -300,7 +300,7 @@
"gsd-core/workflows/undo.md": "96d2775f008b3a85",
"gsd-core/workflows/update.md": "9cad8a8f4baff922",
"gsd-core/workflows/validate-phase.md": "54687a0f2a562fc4",
"gsd-core/workflows/verify-phase.md": "68a74f247fdce962",
"gsd-core/workflows/verify-phase.md": "5caca0023bc88a9a",
"gsd-core/workflows/verify-work.md": "6f482d32e0c8d49a",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",

Some files were not shown because too many files have changed in this diff Show More