Merge remote-tracking branch 'origin/next' into fix/1477-surface-source-marker
# Conflicts: # tests/golden-install-parity.test.cjs
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1687
|
||||
---
|
||||
The `verify-work` security-blocked presentation no longer offers next-phase planning. When security enforcement blocks phase advancement (no `SECURITY.md` produced), the workflow now routes only to the current-phase fix instead of competing `/gsd:plan-phase {next}` and `/gsd:execute-phase {next}` options.
|
||||
@@ -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 heading-style (`### Phase N:`), plain checkbox-list phases (`- [ ] Phase N:` / `- [x] Phase N:`), and the canonical **bold** checklist form the roadmap template emits (`- [ ] **Phase N: Name**`); `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.
|
||||
|
||||
6
.changeset/1682-opencode-mcp-binding.md
Normal file
6
.changeset/1682-opencode-mcp-binding.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1929
|
||||
---
|
||||
<!-- docs-exempt: Phase 5 (#1682) Slice 1a; consolidated Phase 5 (Embeddable Orchestration) docs land with phase completion. -->
|
||||
**OpenCode installs now auto-register the GSD companion MCP server (`mcp.gsd`)** — `--opencode` install writes a `mcp.gsd` entry (local stdio → `gsd-mcp-server`) into `opencode.json`, so OpenCode drives GSD's command + planning-state surface over MCP with no bespoke plugin (ADR-1239 Phase D / #1682). Idempotent and non-clobbering; a user-defined `mcp.gsd` is preserved. (#1682)
|
||||
6
.changeset/1682-opencode-subset-dialect.md
Normal file
6
.changeset/1682-opencode-subset-dialect.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1930
|
||||
---
|
||||
<!-- docs-exempt: Phase 5 (#1682) Slice 1b/c; consolidated Phase 5 docs land with phase completion. -->
|
||||
**OpenCode plugin handles `session.idle` + the `opencode-subset` hook dialect is implemented** — the GSD OpenCode plugin now recognizes `session.idle` (↔ Claude `Stop` lifecycle point), completing the compaction/idle pair (#1914 shipped compaction). The reserved `opencode-subset` dialect gains a consumer — `hookEventSurfaceFor()` in `host-integration.cts` — describing OpenCode's session/tool/file event subset (no workflow-phase events; the engine owns phase sequencing, ADR-1239 §OpenCode binding). Adds a Claude-parity test asserting the plugin covers the full declared subset. (#1682)
|
||||
5
.changeset/1747-new-project-search-provider-keys.md
Normal file
5
.changeset/1747-new-project-search-provider-keys.md
Normal 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.
|
||||
@@ -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.
|
||||
5
.changeset/1772-graphify-update-multi-line-command.md
Normal file
5
.changeset/1772-graphify-update-multi-line-command.md
Normal 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.
|
||||
5
.changeset/1778-thread-workflow-frontmatter-set-args.md
Normal file
5
.changeset/1778-thread-workflow-frontmatter-set-args.md
Normal 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>`).
|
||||
5
.changeset/1817-state-rebuild.md
Normal file
5
.changeset/1817-state-rebuild.md
Normal 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`.
|
||||
7
.changeset/1847-claude-sonnet-5-standard-tier.md
Normal file
7
.changeset/1847-claude-sonnet-5-standard-tier.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1848
|
||||
---
|
||||
**Claude Sonnet 5 is now the `standard` (sonnet) tier model.** The model catalog and provider presets resolve the sonnet/standard tier to `claude-sonnet-5` (GA 2026-06-30) across the Anthropic-backed runtimes (`claude`, `copilot`, and the `anthropic`/`anthropic-fable` presets), plus the OpenRouter-style `anthropic/claude-sonnet-5` for `opencode`/`hermes`, replacing the superseded `claude-sonnet-4-6`. Opus and Haiku tier defaults are unchanged (the `haiku` high-effort preset's escalation slot tracks the current sonnet model). Shipped in 1.6.1. (#1847)
|
||||
|
||||
<!-- docs-exempt: Sonnet 5 operator docs (CONFIGURATION.md model-tier tables, settings-advanced.md) already landed on next via #1851; this fragment only records the already-shipped 1.6.1 change so the 1.7.0 changelog render reflects current shipping code. -->
|
||||
5
.changeset/1906-node-test-causation-mandatory.md
Normal file
5
.changeset/1906-node-test-causation-mandatory.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2001
|
||||
---
|
||||
**Node-test prohibition proofs now require a clean-fixture causation control** — a `node-test` prohibition's fail-first proof no longer accepts a deceptive content-independent negative test (one that reds merely because `GSD_PROHIB_SUBJECT` is *set*, ignoring the subject's content). The `check_clean_fixture` control is now **mandatory** for the `node-test` kind: a descriptor that omits it is un-provable and hard-gates, rather than greening on the violation alone. **Breaking (Hyrum):** a previously-green node-test prohibition with no clean fixture now hard-gates — blast radius is zero in-tree (no `node-test` prohibition ships today). The `lint-rule` kind is unchanged (its subject IS the linted file, no `GSD_PROHIB_SUBJECT` indirection). (#1906)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1938
|
||||
---
|
||||
**Third-party capabilities now work on installed layouts.** `capability install` no longer rejects capabilities with a real `engines.gsd` range as "incompatible with GSD 0.0.0" — the host version is now read from the authoritative `gsd-core/VERSION` file across every runtime and the `capability install` CLI. The installer also now ships the registry generator scripts (`gen-capability-registry.cjs`, `gen-loop-host-contract.cjs`), so installed third-party capabilities actually compose into the loop instead of being silently discarded.
|
||||
7
.changeset/agile-newts-roar.md
Normal file
7
.changeset/agile-newts-roar.md
Normal 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 -->
|
||||
7
.changeset/bold-orcas-wander.md
Normal file
7
.changeset/bold-orcas-wander.md
Normal 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 -->
|
||||
7
.changeset/bold-ravens-wake.md
Normal file
7
.changeset/bold-ravens-wake.md
Normal 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 -->
|
||||
6
.changeset/calm-eagles-tumble.md
Normal file
6
.changeset/calm-eagles-tumble.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1965
|
||||
---
|
||||
<!-- docs-exempt: pi extension is a reference host-plugin module; installation instructions are in the #1944 issue body + ADR-1239 Phase D context. No standalone docs/ file needed. -->
|
||||
**GSD now ships a pi extension** — a real, jiti-loadable ExtensionAPI module (`pi/gsd.cjs`) that registers `/gsd` (dispatches through the GSD command-routing hub) + `gsd_invoke` tool + `tool_call` event, installable at `~/.pi/agent/extensions/`. A reachability test proves the `/gsd` handler dispatches through the engine (keystone wired, not just registered on a mock). (#1965)
|
||||
5
.changeset/clever-rams-march.md
Normal file
5
.changeset/clever-rams-march.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1910
|
||||
---
|
||||
Fixed: probe-core's runProbeCli now fails closed on per-item adapter garbage inside a well-shaped report envelope, matching its documented 'fails closed on adapter garbage' contract.
|
||||
5
.changeset/curious-koalas-gather.md
Normal file
5
.changeset/curious-koalas-gather.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1722
|
||||
---
|
||||
**`/gsd:verify-work` no longer silently terminates when all remaining UAT tests are blocked** — sessions with `blocked_count > 0` and `pending_count == 0` now route to `complete_session` as expected, enabling the zero-issues auto-transition path.
|
||||
5
.changeset/daring-badgers-forage.md
Normal file
5
.changeset/daring-badgers-forage.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1709
|
||||
---
|
||||
Codex reviewer now captures the review via codex's --output-last-message flag instead of redirecting stdout, so Windows process-teardown output no longer pollutes the review file and slips past the empty-output guard.
|
||||
7
.changeset/daring-deer-leap.md
Normal file
7
.changeset/daring-deer-leap.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1802
|
||||
---
|
||||
**Internal: the declarative embedding adapter is now named + bound behind a minimal `HostIntegrationInterface`** — `createDeclarativeAdapter({runtime})` (new `src/adapter-declarative.cts`) delegates in-process to `install-engine`'s `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`, formalizing today's projection path as one of the two embedding adapters behind a common contract (ADR-1239 Phase C-1 / #1680 AC1). Output is byte-identical to today's install (gated by `golden-install-parity`). The full 6-point interface binding surface is deferred until the imperative adapter (AC2) fixes the shape (ADR-1239 open wire-shape question). No user-facing change — the adapter is not yet wired to any runtime path.
|
||||
|
||||
<!-- docs-exempt: internal adapter infrastructure; no user-facing command/flag/config/schema/doc surface -->
|
||||
5
.changeset/eager-ibex-bark.md
Normal file
5
.changeset/eager-ibex-bark.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1844
|
||||
---
|
||||
**`init milestone-op` now counts project_code-prefixed phase directories correctly** — fully shipped milestones using the standard prefixed directory layout no longer report `completed_phases: 0` or stay falsely incomplete. (#1844)
|
||||
5
.changeset/gallant-cats-hum.md
Normal file
5
.changeset/gallant-cats-hum.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1846
|
||||
---
|
||||
**Autonomous reruns now skip phases with deferred verification until you resume them explicitly** — if a prior `/gsd-autonomous` run recorded `verification_deferred_human` or `verification_deferred_gaps`, later reruns no longer drop back into the same prompt loop and instead point you at the saved resume command. (#1846)
|
||||
5
.changeset/gallant-wasps-wave.md
Normal file
5
.changeset/gallant-wasps-wave.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1832
|
||||
---
|
||||
state prune now resolves the current phase from the canonical location — frontmatter current_phase, the Current Phase field, or the prose Phase: line scoped to the ## Current Position section — instead of extracting Phase over the whole document, where stateExtractField's pipe-table fallback could latch onto an unrelated | Phase | N | row (e.g. a historical verification table) and compute a wrong prune cutoff.
|
||||
5
.changeset/graceful-badgers-click.md
Normal file
5
.changeset/graceful-badgers-click.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1765
|
||||
---
|
||||
Phase headers that place a parenthetical tag before the colon (`### Phase 26 (Cluster B): Title`) now resolve and enumerate the same as untagged headers. Previously the resolver returned not-found and `roadmap analyze`/listing silently dropped the phase (wrong phase_count, progress, and next_phase). Tag tolerance is applied at every phase-header read site; untagged and all existing header formats parse unchanged.
|
||||
7
.changeset/graceful-geese-click.md
Normal file
7
.changeset/graceful-geese-click.md
Normal 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 -->
|
||||
5
.changeset/graceful-moles-gather.md
Normal file
5
.changeset/graceful-moles-gather.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1873
|
||||
---
|
||||
Executor and milestone-summary/forensics workflows now call state.* commands with named flags so the named-only router records metrics, decisions, blockers, and session continuity instead of silently dropping positional args.
|
||||
5
.changeset/graceful-wasps-caper.md
Normal file
5
.changeset/graceful-wasps-caper.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1927
|
||||
---
|
||||
**bug-1367 install test no longer fails on Windows CI when hooks/dist isn't pre-built** — the test ran install.js without building its hooks/dist precondition (a gitignored build artifact the unit lane doesn't build), so on a lane without pre-built hooks the installer hit "Failed to install hooks: directory is empty" and the before-hook threw. The test now builds hooks in its own before() (mirroring golden-install-parity). (#1926)
|
||||
7
.changeset/happy-jays-travel.md
Normal file
7
.changeset/happy-jays-travel.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1803
|
||||
---
|
||||
**Internal: the imperative embedding adapter now composes the capability registry behind the same `HostIntegrationInterface`** — `createImperativeAdapter({runtime})` (new `src/adapter-imperative.cts`) calls `loadRegistry({includeInstalled:true})` (first-party-wins + consent + fail-closed — identical trust semantics to the CLI) and binds the engine surface behind the same contract the declarative adapter (AC1) satisfies, plus a `registry` accessor for an in-process host to bind its primitives to (ADR-1239 Phase C-1 / #1680 AC2). Concrete host binding is deferred to Phase 5. No user-facing change — the adapter is not yet wired to any runtime path.
|
||||
|
||||
<!-- docs-exempt: internal adapter infrastructure; no user-facing command/flag/config/schema/doc surface -->
|
||||
5
.changeset/hooks-dist-scoped-ci-race.md
Normal file
5
.changeset/hooks-dist-scoped-ci-race.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1968
|
||||
---
|
||||
Build the gitignored `hooks/dist/` artifact once upfront in `scripts/run-tests.cjs` (the same chokepoint as `ensureBuiltArtifacts`), before any concurrent install test spawns `install.js`. Closes the scoped-CI first-build empty-dir race that intermittently failed install tests with `Failed to install hooks: directory is empty` (e.g. `bug-3683-workflow-colon-namespace-leak`). (#1967)
|
||||
5
.changeset/humble-dogs-gather.md
Normal file
5
.changeset/humble-dogs-gather.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1916
|
||||
---
|
||||
**workstream progress no longer reports shipped milestones as `executing`** — `gsd-tools workstream progress` now derives each workstream's status from authoritative shipped signals (an archived milestone snapshot under milestones/, or a SHIPPED marker in the workstream ROADMAP) instead of trusting the mutable STATE.md `Status` field, so a stale field can never hide a shipped/archived milestone. The output adds `status_source` (`field` | `derived`) and `status_conflict` (true when the derived value disagrees with the stale field). (#1913)
|
||||
7
.changeset/humble-geese-roam.md
Normal file
7
.changeset/humble-geese-roam.md
Normal 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 -->
|
||||
5
.changeset/humble-seals-rest.md
Normal file
5
.changeset/humble-seals-rest.md
Normal 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`.
|
||||
5
.changeset/humble-tunas-munch.md
Normal file
5
.changeset/humble-tunas-munch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1807
|
||||
---
|
||||
reconstructFrontmatter now emits valid YAML for scalars and block-array items that were previously serialized unescaped. Values carrying a YAML indicator plus a literal quote/backslash, embedded control characters, the empty string, a leading YAML indicator, or leading/trailing whitespace are now routed through a properly escaped double-quoted form, so frontmatter round-trips through strict parsers (js-yaml, PyYAML) instead of corrupting the block on the next state sync.
|
||||
5
.changeset/humble-zebras-zip.md
Normal file
5
.changeset/humble-zebras-zip.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1919
|
||||
---
|
||||
**`phases clear` archives phase directories instead of destroying them** — at a milestone switch, committed phase directories were hard-deleted (`rmSync`) with no archive, silently losing browsable phase history (the #1447 dirty-tree guard was a no-op for the common committed case). Phase directories are now moved to `milestones/<version>-phases/` (collision-safe; timestamp fallback when no version resolves), so history survives the switch. The #1447 uncommitted-changes guard is retained as a secondary backstop. (#1871)
|
||||
5
.changeset/kind-tigers-romp.md
Normal file
5
.changeset/kind-tigers-romp.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1998
|
||||
---
|
||||
**Long-running compute can now be externalized as async external jobs instead of blocking the agent turn** — a default-off external-job capability lets executors submit SLURM jobs, commit a .planning/async-jobs manifest, defer SUMMARY.md, and return external_job_waiting; the core loop already reconciles these manifests (#1165), so this adds the producer half (SLURM adapter, pure manifest module, planner/executor fragments, operation policy). (#1105)
|
||||
5
.changeset/lively-lynx-snooze.md
Normal file
5
.changeset/lively-lynx-snooze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1918
|
||||
---
|
||||
**`/gsd-progress` no longer reports a stale root milestone in workstream mode** — in a multi-workstream project with no active workstream set, `gsd-tools query init.progress` silently fell back to root `.planning/STATE.md` (often stale) and reported it confidently. It now fails safe with an actionable error naming the available workstreams and the `--ws`/`workstream set` fix, so a stale root value is never reported. Flat mode and `--ws <name>` are unchanged. (#1912)
|
||||
6
.changeset/lucky-tigers-leap.md
Normal file
6
.changeset/lucky-tigers-leap.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1966
|
||||
---
|
||||
<!-- docs-exempt: VS Code extension is a repo-local reference module; installation instructions are in the #1942 issue body + ADR-1239 Phase D context. No standalone docs/ file needed. -->
|
||||
**GSD now ships a repo-local VS Code extension** — a buildable extension (`vscode/extension.js` + `vscode/package.json`) that registers `gsd.invoke` (dispatches through the GSD command-routing hub) in the VS Code command palette. A reachability test proves the handler dispatches through the engine (keystone wired). Not Marketplace-published; mirrors the OpenCode plugin's bar. (#1966)
|
||||
5
.changeset/merry-mice-travel.md
Normal file
5
.changeset/merry-mice-travel.md
Normal 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).
|
||||
7
.changeset/nimble-ibex-tumble.md
Normal file
7
.changeset/nimble-ibex-tumble.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1922
|
||||
---
|
||||
Document Claude Code's advisor-tool inheritance in the model-profiles reference: the session-level advisor is inherited by all GSD subagents and composes with per-agent tiering, with candidate executor/advisor pairings, when it is worth enabling, and the session-level (no per-agent control) constraint.
|
||||
|
||||
<!-- docs-exempt: the changed file gsd-core/references/model-profiles.md IS the documentation; it lives under gsd-core/references/, not docs/ -->
|
||||
5
.changeset/nimble-jays-roar.md
Normal file
5
.changeset/nimble-jays-roar.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1861
|
||||
---
|
||||
GSD Core ships a `.claude-plugin/marketplace.json` marketplace manifest so Claude-plugin-compatible runtimes (ZCODE et al.) can discover and install gsd-core from a custom marketplace source. Additive — the existing `.claude-plugin/plugin.json` and the Claude Code install path are unchanged. The catalog version (`plugins[0].version`) tracks `package.json` via the release version-sync.
|
||||
5
.changeset/patient-mice-greet.md
Normal file
5
.changeset/patient-mice-greet.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1843
|
||||
---
|
||||
**`init milestone-op` now ignores backlog `999.x` headings when counting milestone phases** — parked backlog items no longer inflate `phase_count` or pin `all_phases_complete` false for an otherwise finished milestone. (#1843)
|
||||
5
.changeset/plucky-bears-snooze.md
Normal file
5
.changeset/plucky-bears-snooze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1924
|
||||
---
|
||||
**Phase archival is now wired end-to-end across the milestone lifecycle** — finishes the #1871 follow-up: `phases archive` is now a real command (the half-wired alias is routed, no longer errors Unknown), `milestone complete` archives phase dirs by default (`--no-archive-phases` opts out), and `new-milestone` §6 stages the archive move + source removal in the same commit so history is preserved atomically rather than left as orphaned uncommitted deletions. (#1871)
|
||||
7
.changeset/plucky-moles-sing.md
Normal file
7
.changeset/plucky-moles-sing.md
Normal 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 -->
|
||||
5
.changeset/proud-bears-purr.md
Normal file
5
.changeset/proud-bears-purr.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1798
|
||||
---
|
||||
**`/gsd:next` smart-entry workflow** — adds a state-aware entry point that classifies the current project situation (no-project, blocked, verify-failed, planning, executing, verify-pending, complete, and more) and recommends the right next GSD command. The `gsd-tools smart-entry [--json]` classifier handles phase ordering including decimal phase IDs; the `/gsd:next` skill surfaces the workflow with tiered fallback behavior.
|
||||
5
.changeset/proud-bears-roam.md
Normal file
5
.changeset/proud-bears-roam.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1923
|
||||
---
|
||||
OpenCode now runs GSD's lifecycle safety hooks (prompt-injection guard, read-before-edit guard, injection scanner, worktree/workflow guards, context monitor) via a native plugin installed to `~/.config/opencode/plugins/gsd-core.js`. OpenCode declares `hooksSurface: 'none'`, so these hooks were previously inert; the plugin bridges OpenCode's event bus onto GSD's existing hook scripts. Installed automatically by `npx @opengsd/gsd-core --opencode` and removed on uninstall.
|
||||
5
.changeset/proud-rams-greet.md
Normal file
5
.changeset/proud-rams-greet.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1845
|
||||
---
|
||||
**Roadmap phase lookup now ignores fenced examples and the backlog sentinel lane** — `roadmap get-phase` and `init plan-phase` no longer return fenced sample headings as real phases or treat `999.x` backlog items as active milestone work. (#1845)
|
||||
7
.changeset/rapid-orcas-sing.md
Normal file
7
.changeset/rapid-orcas-sing.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1934
|
||||
---
|
||||
**Lazy-split `plan-phase.md` into a `steps/` directory** — ~4.7 KB lighter eager context per `/gsd-plan-phase` call via byte-invariant progressive disclosure (ADR-1610). (#1852)
|
||||
|
||||
<!-- docs-exempt: internal workflow structure refactor; byte-invariant lazy extraction (ADR-1610), no user-facing behavior or doc change -->
|
||||
5
.changeset/rapid-pumas-click.md
Normal file
5
.changeset/rapid-pumas-click.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1917
|
||||
---
|
||||
**`milestone complete --ws` now archives into the workstream instead of root** — the archive paths (MILESTONES.md, the milestones/ archive dir, and the per-version MILESTONE-AUDIT.md) were hardcoded to root `.planning/`, so a workstream milestone close scattered its artifacts into root and never produced a workstream-local archive. They now derive from the workstream-aware planning base (`planningPaths(cwd).planning`); flat-mode (no --ws) is unchanged. (#1911)
|
||||
5
.changeset/serene-birds-rest.md
Normal file
5
.changeset/serene-birds-rest.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1946
|
||||
---
|
||||
**Host-integration descriptors now carry an `extensionEvents` vocabulary** — the extension-system event surface (OpenCode, pi) is a separate descriptor field from managed `hookEvents`, so OpenCode declares `extensionEvents:opencode` without conflicting with the hooksSurface:none invariant. (#1946)
|
||||
5
.changeset/serene-lemurs-march.md
Normal file
5
.changeset/serene-lemurs-march.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1868
|
||||
---
|
||||
**GSD subagents now self-load configured agent_skills regardless of orchestrator bash** — projects that map skills via `.planning/config.json` `agent_skills.<agent-type>` no longer silently lose them on `/gsd-autonomous` or Cursor, where `Skill()`-delegated workflow bash init did not reliably run. Each of the 22 consumer agents queries its own type at init and reads the listed skills, with a dedup guard so runtimes that also inject orchestrator-side (Claude Code) never carry two copies. (#1866)
|
||||
7
.changeset/sturdy-voles-dart.md
Normal file
7
.changeset/sturdy-voles-dart.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1800
|
||||
---
|
||||
**Internal: install/uninstall runtime labels are now sourced from a single `getRuntimeLabel` lookup** — the two duplicated `runtimeLabel` assignment chains in `bin/install.js` (uninstall + install) are collapsed into one curated label table in `runtime-name-policy.cts`, sibling to the registry-derived `getDirName` (ADR-1239 Phase B, #1679). Install output is byte-identical for all 16 runtimes (golden-parity asserted). Two console-label inconsistencies are normalized as a side effect: `kimi` shows 'Kimi CLI' in both sites, and `cline` uninstall no longer falls through to 'Claude Code'.
|
||||
|
||||
<!-- docs-exempt: internal refactor; only cosmetic console-label normalization with no doc surface to update -->
|
||||
5
.changeset/tidy-pumas-munch.md
Normal file
5
.changeset/tidy-pumas-munch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1909
|
||||
---
|
||||
Fixed: a hand-authored non-inferable backstop truth with a stray trailing space or surrounding quotes no longer silently grades green — it correctly abstains (insufficient_spec), restoring the #1154 honest-verifier guarantee.
|
||||
7
.changeset/wise-elks-caper.md
Normal file
7
.changeset/wise-elks-caper.md
Normal 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 -->
|
||||
7
.changeset/witty-seals-snooze.md
Normal file
7
.changeset/witty-seals-snooze.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1801
|
||||
---
|
||||
**Internal: the installer's runtime → global-config-home hook-pathogen fragment is now a single `getGlobalConfigHomeFragment` lookup** — the 14-branch `if (runtime === 'x') return "'...'"` chain in `getConfigDirFromHome` (`bin/install.js`, the hook `path.join()` codegen mapping) is collapsed into one table in `runtime-name-policy.cts`, sibling to `getRuntimeLabel` (ADR-1239 Phase B, #1679 AC2 slice 2). Generated hook output is byte-identical for all 16 runtimes (golden-parity asserted); antigravity's dynamic env-overridable resolution is preserved in the caller. No user-facing change.
|
||||
|
||||
<!-- docs-exempt: internal refactor; hook codegen path-fragment table, no user-facing doc surface -->
|
||||
20
.claude-plugin/marketplace.json
Normal file
20
.claude-plugin/marketplace.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"description": "Marketplace for GSD Core — meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"owner": {
|
||||
"name": "open-gsd",
|
||||
"url": "https://github.com/open-gsd"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"version": "1.7.0-rc.2",
|
||||
"source": "./",
|
||||
"author": {
|
||||
"name": "open-gsd",
|
||||
"url": "https://github.com/open-gsd"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"displayName": "GSD Core",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0-rc.2",
|
||||
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"author": {
|
||||
"name": "open-gsd",
|
||||
|
||||
2
.github/workflows/auto-backmerge.yml
vendored
2
.github/workflows/auto-backmerge.yml
vendored
@@ -115,7 +115,7 @@ jobs:
|
||||
# filter those out. A substantive change still parks: it leaves
|
||||
# non-"version" lines (deps in package.json; resolved/integrity in the
|
||||
# lockfile when a dependency actually changes).
|
||||
VERSION_STAMP_MANIFESTS='package.json package-lock.json .claude-plugin/plugin.json gemini-extension.json'
|
||||
VERSION_STAMP_MANIFESTS='package.json package-lock.json .claude-plugin/plugin.json .claude-plugin/marketplace.json gemini-extension.json'
|
||||
DROPPED=$(printf '%s\n' "$DROPPED" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
case " $VERSION_STAMP_MANIFESTS " in
|
||||
|
||||
2
.github/workflows/auto-label-issues.yml
vendored
2
.github/workflows/auto-label-issues.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -68,7 +68,17 @@ build/
|
||||
# Published via prepublishOnly; built before test via pretest. Grows as modules migrate.
|
||||
/tsconfig.build.tsbuildinfo
|
||||
/gsd-core/bin/lib/host-integration.cjs
|
||||
/gsd-core/bin/lib/host-integration-sdk.cjs
|
||||
/gsd-core/bin/lib/handshake-serialized.cjs
|
||||
/gsd-core/bin/lib/install-engine.cjs
|
||||
/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/mcp-server.cjs
|
||||
/gsd-core/bin/lib/external-descriptor-trust.cjs
|
||||
/gsd-core/bin/lib/cli-skew-check.cjs
|
||||
/gsd-core/bin/lib/capability-loader.cjs
|
||||
/gsd-core/bin/lib/capability-source.cjs
|
||||
@@ -102,6 +112,7 @@ build/
|
||||
/gsd-core/bin/lib/installer-migration-report.cjs
|
||||
/gsd-core/bin/lib/prompt-budget.cjs
|
||||
/gsd-core/bin/lib/secrets.cjs
|
||||
/gsd-core/bin/lib/smart-entry.cjs
|
||||
/gsd-core/bin/lib/phase-lifecycle.cjs
|
||||
/gsd-core/bin/lib/workstream-name-policy.cjs
|
||||
/gsd-core/bin/lib/decisions.cjs
|
||||
|
||||
711
.opencode/plugins/gsd-core.js
Normal file
711
.opencode/plugins/gsd-core.js
Normal file
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* GSD plugin for OpenCode.ai (CommonJS)
|
||||
*
|
||||
* Architecture: SUBPROCESS REUSE. Instead of re-implementing hook logic inside
|
||||
* the plugin, this file is a thin adapter that spawns the existing Claude Code
|
||||
* hook scripts under hooks/ as child processes. The hooks speak a stable
|
||||
* protocol (JSON on stdin, JSON + exit code on stdout); this adapter:
|
||||
* 1. Translates OpenCode plugin events into Claude Code hook payloads
|
||||
* 2. Spawns `node <HOOKS_DIR>/<hook>.js` with the payload on stdin
|
||||
* 3. Translates hook output back into OpenCode semantics
|
||||
* - block → throw Error (OpenCode returns the error to the model)
|
||||
* - advisory → output.metadata + console.error (best-effort surfacing)
|
||||
*
|
||||
* Namespace conversion (/gsd:xxx → /gsd-xxx) reuses scripts/fix-slash-commands.cjs
|
||||
* via require(), keeping the single source of truth.
|
||||
*
|
||||
* ── Two distribution shapes, one adapter (issue #1914) ─────────────────────
|
||||
* This single file serves both distribution paths, distinguished at load time
|
||||
* by REPO_ROOT (path.resolve(__dirname, "../..")):
|
||||
*
|
||||
* • Option 1 — file copy (the supported GSD path). `bin/install.js` copies
|
||||
* this file to <opencodeConfigDir>/plugins/gsd-core.js, so REPO_ROOT is the
|
||||
* OpenCode config dir. GSD's own install already stages `hooks/*.js` and
|
||||
* `gsd-core/` there (ADR-857 skips hook *registration* for OpenCode, not the
|
||||
* file copy), so the hook bridge and content rewriting resolve natively.
|
||||
* Commands/agents/skills are ALREADY registered by GSD's native file copy in
|
||||
* this mode, so the plugin's own config-hook registration is redundant and is
|
||||
* SKIPPED (see IS_PACKAGE_TREE) to avoid double-registration.
|
||||
*
|
||||
* • Option 2 — package / git-spec. When loaded from the package tree (npm
|
||||
* `main`, or an OpenCode git-spec install), REPO_ROOT is the package root and
|
||||
* the source layout (commands/gsd/, agents/, skills/) is present. Here the
|
||||
* plugin IS the sole registrar, so it registers commands/agents/skills too.
|
||||
*
|
||||
* IS_PACKAGE_TREE keys off the presence of the SOURCE command layout
|
||||
* (commands/gsd/), which only exists in the package tree — never in an installed
|
||||
* config dir (that uses the flattened command/ layout). The hook bridge and
|
||||
* Read-time content rewriting run in BOTH modes; only the config-hook
|
||||
* registration of commands/agents/skills is gated.
|
||||
*
|
||||
* Runtime-specific hooks are deliberately excluded:
|
||||
* - gsd-statusline.js / gsd-update-banner.js (Claude Code statusline)
|
||||
* - gsd-cursor-*.js (Cursor-specific)
|
||||
* - *.sh scripts (invoked directly by commands/agents, not hook events)
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
// Resolve REPO_ROOT to the directory that actually holds the GSD payload
|
||||
// (hooks/ + gsd-core/). This must work across three physical layouts because a
|
||||
// single adapter file serves both distribution shapes (see header):
|
||||
// • package/git-spec tree: <root>/.opencode/plugins/gsd-core.js → <root>
|
||||
// • global file-copy: ~/.config/opencode/plugins/gsd-core.js → ~/.config/opencode
|
||||
// • local file-copy: <proj>/.opencode/plugins/gsd-core.js → <proj>/.opencode
|
||||
// A fixed "../.." only works for the first; the copied layouts sit one level
|
||||
// shallower. Walking up to the first ancestor containing BOTH payload markers
|
||||
// resolves all three deterministically. Falls back to the package-tree
|
||||
// assumption ("../..") if no ancestor matches (keeps graceful degradation).
|
||||
function resolveRepoRoot(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (
|
||||
fs.existsSync(path.join(dir, "hooks")) &&
|
||||
fs.existsSync(path.join(dir, "gsd-core"))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break; // filesystem root
|
||||
dir = parent;
|
||||
}
|
||||
// No ancestor carried both markers (broken/partial layout — the plugin can't
|
||||
// function regardless). Fall back to the package-tree assumption ("../.."),
|
||||
// matching the historical fixed-depth behavior and the .opencode/plugins/
|
||||
// source layout.
|
||||
return path.resolve(startDir, "../..");
|
||||
}
|
||||
|
||||
// CJS: __dirname is a global, no need to derive from import.meta.url
|
||||
const REPO_ROOT = resolveRepoRoot(__dirname);
|
||||
const HOOKS_DIR = path.join(REPO_ROOT, "hooks");
|
||||
const COMMANDS = path.join(REPO_ROOT, "commands", "gsd");
|
||||
const AGENTS = path.join(REPO_ROOT, "agents");
|
||||
const SKILLS = path.join(REPO_ROOT, "skills");
|
||||
const GSD_CORE = path.join(REPO_ROOT, "gsd-core");
|
||||
|
||||
// True only when loaded from the package/source tree (Option 2), detected by the
|
||||
// presence of the SOURCE command layout (commands/gsd/). In an installed OpenCode
|
||||
// config dir (Option 1) this directory is absent — the flattened command/ layout
|
||||
// is used instead — so the plugin skips its own command/agent/skill registration
|
||||
// and lets GSD's native file copy own that surface (avoids double-registration).
|
||||
const IS_PACKAGE_TREE = fs.existsSync(COMMANDS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Namespace conversion — reuse the single source of truth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _cmdNames = null;
|
||||
let _transformFn = null;
|
||||
|
||||
/**
|
||||
* Lazily load scripts/fix-slash-commands.cjs and cache the transform function
|
||||
* + command name list. Returns null if the module is unavailable (the plugin
|
||||
* still works, just without namespace conversion).
|
||||
*/
|
||||
function getNamespaceConverter() {
|
||||
if (_transformFn) return _transformFn;
|
||||
try {
|
||||
const mod = require(
|
||||
path.join(REPO_ROOT, "scripts", "fix-slash-commands.cjs"),
|
||||
);
|
||||
_cmdNames = mod.readCmdNames();
|
||||
_transformFn = mod.transformContentToHyphen;
|
||||
return _transformFn;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session state — tracked across plugin hook invocations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentSessionId = null;
|
||||
let currentCwd = process.cwd();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool name / argument mapping (OpenCode ↔ Claude Code)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOL_NAME_MAP = {
|
||||
read: "Read",
|
||||
write: "Write",
|
||||
edit: "Edit",
|
||||
apply_patch: "MultiEdit",
|
||||
multi_edit: "MultiEdit",
|
||||
bash: "Bash",
|
||||
webfetch: "WebFetch",
|
||||
web_search: "WebSearch",
|
||||
websearch: "WebSearch",
|
||||
task: "Task",
|
||||
subagent: "Task",
|
||||
};
|
||||
|
||||
function mapToolName(tool) {
|
||||
if (!tool) return "";
|
||||
return TOOL_NAME_MAP[String(tool).toLowerCase()] || tool;
|
||||
}
|
||||
|
||||
// Build a Claude-style `tool_input` object from OpenCode's `output.args`.
|
||||
function mapToolInput(args) {
|
||||
const input = {};
|
||||
if (!args || typeof args !== "object") return input;
|
||||
|
||||
// File-path keys (OpenCode uses filePath/path; Claude uses file_path)
|
||||
const filePath = args.filePath || args.path || args.file_path;
|
||||
if (filePath) input.file_path = filePath;
|
||||
|
||||
// Content for Write
|
||||
if (args.content !== undefined) input.content = args.content;
|
||||
|
||||
// Edit patch fields
|
||||
if (args.new_string !== undefined) input.new_string = args.new_string;
|
||||
if (args.newString !== undefined) input.new_string = args.newString;
|
||||
if (args.old_string !== undefined) input.old_string = args.old_string;
|
||||
if (args.oldString !== undefined) input.old_string = args.oldString;
|
||||
|
||||
// Bash command
|
||||
if (args.command !== undefined) input.command = args.command;
|
||||
|
||||
// Web
|
||||
if (args.url !== undefined) input.url = args.url;
|
||||
if (args.query !== undefined) input.query = args.query;
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook subprocess runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Spawn a Claude Code hook script and pipe a JSON payload to its stdin.
|
||||
*
|
||||
* Hooks follow the convention:
|
||||
* - stdout: JSON object (decision/advisory) or empty
|
||||
* - exit 0: allow (with optional advisory JSON on stdout)
|
||||
* - exit 2: block (Claude convention; reason in stdout JSON)
|
||||
* - any error: exit 0 silently (hooks swallow their own errors)
|
||||
*
|
||||
* @param {string} hookFile filename under hooks/, e.g. "gsd-prompt-guard.js"
|
||||
* @param {object} payload stdin JSON (hook_event_name, tool_name, ...)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.timeout=8000] spawn timeout in ms
|
||||
* @param {string} [opts.cwd] working directory for the child
|
||||
* @returns {{ stdout: string, exitCode: number, timedOut: boolean }}
|
||||
*/
|
||||
function runHook(hookFile, payload, opts = {}) {
|
||||
const hookPath = path.join(HOOKS_DIR, hookFile);
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
return { stdout: "", exitCode: 0, timedOut: false };
|
||||
}
|
||||
const timeout = opts.timeout ?? 8000;
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync(process.execPath, [hookPath], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: "utf8",
|
||||
timeout,
|
||||
cwd: opts.cwd || currentCwd,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Spawn failure — never break the tool call
|
||||
return { stdout: "", exitCode: 0, timedOut: false };
|
||||
}
|
||||
|
||||
const stdout = (result.stdout || "").trim();
|
||||
const exitCode = result.status == null ? 0 : result.status;
|
||||
return { stdout, exitCode, timedOut: result.signal === "SIGTERM" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook output translation → OpenCode semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a hook's stdout and apply its effect to the OpenCode output object.
|
||||
*
|
||||
* - Block → throw Error(parsed.reason) so OpenCode aborts the tool call
|
||||
* - Advisory→ append to output.metadata._gsdAdvisory[] and log to stderr
|
||||
* - Silent → no-op
|
||||
*
|
||||
* @param {{ stdout: string, exitCode: number }} hookResult
|
||||
* @param {object} [output] OpenCode mutable output object (optional)
|
||||
*/
|
||||
function handleHookResult(hookResult, output) {
|
||||
const { stdout, exitCode } = hookResult;
|
||||
if (!stdout && exitCode !== 2) return; // silent allow
|
||||
|
||||
let parsed = null;
|
||||
if (stdout) {
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
// Non-JSON stdout (e.g. a stray log) — treat exit 2 as hard block, else allow
|
||||
}
|
||||
}
|
||||
|
||||
// Block: explicit decision OR Claude exit-code-2 convention
|
||||
const isBlock = exitCode === 2 || (parsed && parsed.decision === "block");
|
||||
if (isBlock) {
|
||||
const reason =
|
||||
(parsed && parsed.reason) || "Blocked by GSD hook (no reason provided).";
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
// Advisory: inject additionalContext into metadata + log
|
||||
const advisory =
|
||||
parsed &&
|
||||
parsed.hookSpecificOutput &&
|
||||
parsed.hookSpecificOutput.additionalContext;
|
||||
if (advisory) {
|
||||
if (output) {
|
||||
output.metadata = output.metadata || {};
|
||||
// Accumulate: a single tool call can run several advisory hooks in
|
||||
// sequence (prompt guard, read guard, worktree guard, workflow guard).
|
||||
// Storing a scalar would let a later advisory clobber an earlier one, so
|
||||
// collect them all.
|
||||
if (!Array.isArray(output.metadata._gsdAdvisory)) {
|
||||
output.metadata._gsdAdvisory = [];
|
||||
}
|
||||
output.metadata._gsdAdvisory.push(advisory);
|
||||
}
|
||||
// Best-effort visibility when metadata isn't surfaced to the model
|
||||
console.error(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter helpers (for config registration)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!m) return { frontmatter: {}, body: content };
|
||||
const fm = {};
|
||||
for (const line of m[1].split("\n")) {
|
||||
const i = line.indexOf(":");
|
||||
if (i > 0) {
|
||||
let v = line.slice(i + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
fm[line.slice(0, i).trim()] = v;
|
||||
}
|
||||
}
|
||||
return { frontmatter: fm, body: m[2] };
|
||||
}
|
||||
|
||||
// Rewrite @~/.claude/ includes to point at the repo root.
|
||||
// Also applies /gsd:xxx → /gsd-xxx namespace conversion via the shared
|
||||
// transform from scripts/fix-slash-commands.cjs (single source of truth).
|
||||
function rewriteRefs(content) {
|
||||
let out = content.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
|
||||
const transform = getNamespaceConverter();
|
||||
if (transform && _cmdNames && _cmdNames.length) {
|
||||
out = transform(out, _cmdNames);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadDir(dir, keyFn, valFn) {
|
||||
const result = {};
|
||||
if (!fs.existsSync(dir)) return result;
|
||||
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
||||
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
result[keyFn(f)] = valFn(body, frontmatter, f);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime content transform — for Read tool results on GSD-managed files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Directories whose .md files may contain ~/.claude/ paths and gsd: namespace
|
||||
// refs. When the model reads these via the Read tool, we transparently rewrite
|
||||
// both so OpenCode sees correct paths and hyphen-form command names.
|
||||
const GSD_MANAGED_DIRS = [
|
||||
path.join(GSD_CORE, "workflows"),
|
||||
path.join(GSD_CORE, "references"),
|
||||
path.join(GSD_CORE, "templates"),
|
||||
path.join(GSD_CORE, "contexts"),
|
||||
COMMANDS,
|
||||
AGENTS,
|
||||
SKILLS,
|
||||
];
|
||||
|
||||
function isGsdManagedFile(filePath) {
|
||||
if (!filePath) return false;
|
||||
const resolved = path.resolve(filePath);
|
||||
return GSD_MANAGED_DIRS.some(
|
||||
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
|
||||
);
|
||||
}
|
||||
|
||||
// Rewrite content for OpenCode consumption:
|
||||
// 1. @-include paths: @~/.claude/ → @<REPO_ROOT>/
|
||||
// 2. plain-text paths: ~/.claude/gsd-core/ → <GSD_CORE>/
|
||||
// 3. namespace: gsd:xxx → gsd-xxx (via fix-slash-commands.cjs)
|
||||
function rewriteContent(content) {
|
||||
let out = content;
|
||||
out = out.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
|
||||
out = out.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
|
||||
const transform = getNamespaceConverter();
|
||||
if (transform && _cmdNames && _cmdNames.length) {
|
||||
out = transform(out, _cmdNames);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills cache — copy SKILL.md files with rewritten @-include paths
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// OpenCode's skill loader reads SKILL.md files directly from disk and resolves
|
||||
// @-includes internally — this bypasses our tool.execute hooks. To make
|
||||
// @~/.claude/gsd-core/... includes resolve, we copy all SKILL.md files to a
|
||||
// cache directory with paths rewritten to the actual GSD_CORE location.
|
||||
//
|
||||
// Only used in package-tree mode (Option 2). In an installed OpenCode config
|
||||
// dir (Option 1) skills are already staged + registered by GSD's native file
|
||||
// copy, so we never register skills from the plugin (see IS_PACKAGE_TREE).
|
||||
|
||||
const SKILLS_CACHE = path.join(
|
||||
os.homedir(),
|
||||
".cache",
|
||||
"opencode",
|
||||
"gsd-skills",
|
||||
);
|
||||
|
||||
function prepareSkillsCache() {
|
||||
if (!fs.existsSync(SKILLS)) return null;
|
||||
fs.mkdirSync(SKILLS_CACHE, { recursive: true });
|
||||
for (const dir of fs.readdirSync(SKILLS)) {
|
||||
const srcFile = path.join(SKILLS, dir, "SKILL.md");
|
||||
if (!fs.existsSync(srcFile)) continue;
|
||||
const raw = fs.readFileSync(srcFile, "utf8");
|
||||
// Rewrite @-include paths only; namespace conversion is handled at
|
||||
// Read-time via tool.execute.after for workflow/reference files.
|
||||
const rewritten = raw
|
||||
.replace(/@~\/\.claude\/gsd-core\//g, `@${GSD_CORE}/`)
|
||||
.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
|
||||
const destDir = path.join(SKILLS_CACHE, dir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, "SKILL.md"), rewritten);
|
||||
}
|
||||
return SKILLS_CACHE;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Plugin entry
|
||||
// ===========================================================================
|
||||
|
||||
const GsdCorePlugin = async ({ directory } = {}) => {
|
||||
if (directory) currentCwd = directory;
|
||||
|
||||
return {
|
||||
// ── Config: register commands / agents / skills paths ──────────────
|
||||
// Only in package-tree mode (Option 2). In an installed config dir
|
||||
// (Option 1) GSD's native file copy already registered these, so the
|
||||
// plugin stays out of registration to avoid double-registering.
|
||||
config: async (config) => {
|
||||
if (!IS_PACKAGE_TREE) return;
|
||||
|
||||
// Commands (commands/gsd/*.md → gsd-<name>)
|
||||
config.command = config.command || {};
|
||||
const cmds = loadDir(
|
||||
COMMANDS,
|
||||
(f) => "gsd-" + f.slice(0, -3),
|
||||
(body, fm, name) => ({
|
||||
template: rewriteRefs(body.trim()),
|
||||
description: fm.description || `GSD ${name.slice(0, -3)} command`,
|
||||
}),
|
||||
);
|
||||
for (const [k, v] of Object.entries(cmds)) {
|
||||
if (!config.command[k]) config.command[k] = v;
|
||||
}
|
||||
|
||||
// Agents (agents/*.md)
|
||||
config.agent = config.agent || {};
|
||||
const agents = loadDir(
|
||||
AGENTS,
|
||||
(f) => f.slice(0, -3),
|
||||
(body, fm, name) => ({
|
||||
prompt: rewriteRefs(body.trim()),
|
||||
description: fm.description || `GSD ${name.slice(0, -3)} agent`,
|
||||
mode: fm.mode || "subagent",
|
||||
}),
|
||||
);
|
||||
for (const [k, v] of Object.entries(agents)) {
|
||||
if (!config.agent[k]) config.agent[k] = v;
|
||||
}
|
||||
|
||||
// Skills — copy SKILL.md files to cache with rewritten @-include paths,
|
||||
// then register the cache directory. OpenCode's skill loader reads
|
||||
// SKILL.md from disk and resolves @-includes internally (bypassing our
|
||||
// tool.execute hooks), so we must pre-process the files.
|
||||
const skillsCache = prepareSkillsCache();
|
||||
config.skills = config.skills || {};
|
||||
config.skills.paths = config.skills.paths || [];
|
||||
const skillsPath = skillsCache || SKILLS;
|
||||
if (!config.skills.paths.includes(skillsPath)) {
|
||||
config.skills.paths.push(skillsPath);
|
||||
}
|
||||
},
|
||||
|
||||
// ── shell.env ───────────────────────────────────────────────────────
|
||||
"shell.env": async (_input, output) => {
|
||||
output.env = output.env || {};
|
||||
output.env.GSD_DIR = GSD_CORE;
|
||||
},
|
||||
|
||||
// ── tool.execute.before — PreToolUse hooks ─────────────────────────
|
||||
"tool.execute.before": async (input, output) => {
|
||||
const claudeTool = mapToolName(input.tool);
|
||||
const toolInput = mapToolInput(output.args || {});
|
||||
const cwd = currentCwd;
|
||||
|
||||
// 0. Read path rewrite — redirect ~/.claude/gsd-core/ to actual GSD_CORE
|
||||
// so the model can read workflow/reference/template files that SKILL.md
|
||||
// and command templates reference via the canonical Claude path.
|
||||
if (claudeTool === "Read" && toolInput.file_path) {
|
||||
const original = toolInput.file_path;
|
||||
const rewritten = original
|
||||
.replace(/^~\/\.claude\/gsd-core\//, GSD_CORE + "/")
|
||||
.replace(/(?:.*)\/\.claude\/gsd-core\//, GSD_CORE + "/");
|
||||
if (rewritten !== original) {
|
||||
const args = output.args || {};
|
||||
if (args.filePath) args.filePath = rewritten;
|
||||
else if (args.path) args.path = rewritten;
|
||||
else if (args.file_path) args.file_path = rewritten;
|
||||
else args.filePath = rewritten;
|
||||
}
|
||||
}
|
||||
|
||||
const basePayload = {
|
||||
hook_event_name: "PreToolUse",
|
||||
cwd,
|
||||
};
|
||||
// NOTE: session_id intentionally omitted for PreToolUse hooks.
|
||||
// gsd-read-guard.js treats a non-empty session_id as a Claude Code
|
||||
// session and skips its advisory. On OpenCode we WANT the advisory.
|
||||
const prePayload = (overrides = {}) => ({
|
||||
...basePayload,
|
||||
tool_name: claudeTool,
|
||||
tool_input: toolInput,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const isWriteLike = ["Write", "Edit", "MultiEdit"].includes(claudeTool);
|
||||
|
||||
// 1. gsd-prompt-guard.js — injection scan on .planning/ writes
|
||||
if (claudeTool === "Write" || claudeTool === "Edit") {
|
||||
const r = runHook("gsd-prompt-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
|
||||
// 2. gsd-read-guard.js — read-before-edit advisory
|
||||
if (claudeTool === "Write" || claudeTool === "Edit") {
|
||||
const r = runHook("gsd-read-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
|
||||
// 3. gsd-worktree-path-guard.js — hard-block edits outside worktree
|
||||
if (isWriteLike) {
|
||||
const r = runHook("gsd-worktree-path-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
|
||||
// 4. gsd-workflow-guard.js — workflow advisory + git-force-add block
|
||||
// (covers Write/Edit/MultiEdit AND Bash force-add detection)
|
||||
if (isWriteLike || claudeTool === "Bash") {
|
||||
const r = runHook("gsd-workflow-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
},
|
||||
|
||||
// ── tool.execute.after — PostToolUse hooks ─────────────────────────
|
||||
"tool.execute.after": async (input, output) => {
|
||||
const claudeTool = mapToolName(input.tool);
|
||||
// NOTE: In the `after` hook, `args` lives on `input` (not `output`).
|
||||
// The `output` object only has { title, output, metadata }.
|
||||
const toolInput = mapToolInput(input.args || {});
|
||||
const cwd = currentCwd;
|
||||
|
||||
// GSD content transform — rewrite paths + namespace in Read results
|
||||
// BEFORE injection scanning so the scanner sees the final content.
|
||||
if (
|
||||
claudeTool === "Read" &&
|
||||
output.output &&
|
||||
isGsdManagedFile(toolInput.file_path)
|
||||
) {
|
||||
const content =
|
||||
typeof output.output === "string"
|
||||
? output.output
|
||||
: String(output.output);
|
||||
output.output = rewriteContent(content);
|
||||
}
|
||||
|
||||
// gsd-read-injection-scanner.js — scan Read/WebFetch/WebSearch results
|
||||
if (
|
||||
claudeTool === "Read" ||
|
||||
claudeTool === "WebFetch" ||
|
||||
claudeTool === "WebSearch"
|
||||
) {
|
||||
const payload = {
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: claudeTool,
|
||||
tool_input: toolInput,
|
||||
tool_response: output.output,
|
||||
cwd,
|
||||
};
|
||||
const r = runHook("gsd-read-injection-scanner.js", payload);
|
||||
handleHookResult(r, output);
|
||||
return;
|
||||
}
|
||||
|
||||
// gsd-context-monitor.js — context usage warnings (Bash/Edit/Write/Task/...)
|
||||
// Only meaningful when a session_id is tracked (writes metrics sentinel).
|
||||
if (currentSessionId) {
|
||||
const payload = {
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: claudeTool,
|
||||
tool_input: toolInput,
|
||||
session_id: currentSessionId,
|
||||
cwd,
|
||||
};
|
||||
const r = runHook("gsd-context-monitor.js", payload);
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
},
|
||||
|
||||
// ── experimental.session.compacting — PreCompact ───────────────────
|
||||
"experimental.session.compacting": async (_input, output) => {
|
||||
if (!currentSessionId) return;
|
||||
const payload = {
|
||||
hook_event_name: "PreCompact",
|
||||
session_id: currentSessionId,
|
||||
cwd: currentCwd,
|
||||
};
|
||||
const r = runHook("gsd-context-monitor.js", payload);
|
||||
handleHookResult(r, output);
|
||||
|
||||
// Also inject a GSD compaction breadcrumb (mirrors the original plugin)
|
||||
output.context = output.context || [];
|
||||
output.context.push(
|
||||
`[GSD] Active session: ${currentSessionId}. Preserve any in-flight phase/plan state.`,
|
||||
);
|
||||
},
|
||||
|
||||
// ── General event subscriptions ─────────────────────────────────────
|
||||
event: async ({ event }) => {
|
||||
// session.created → SessionStart hooks
|
||||
if (event.type === "session.created") {
|
||||
// Track session for context-monitor payloads.
|
||||
// SDK type EventSessionCreated: { properties: { info: Session } }
|
||||
// Session has `id` and `directory` (not `cwd`).
|
||||
const info = event.properties?.info;
|
||||
currentSessionId =
|
||||
info?.id || event.sessionID || event.session_id || null;
|
||||
if (info?.directory) currentCwd = info.directory;
|
||||
|
||||
// gsd-ensure-canonical-path.js — no stdin dependency; silent
|
||||
runHook("gsd-ensure-canonical-path.js", {
|
||||
hook_event_name: "SessionStart",
|
||||
session_id: currentSessionId,
|
||||
cwd: currentCwd,
|
||||
});
|
||||
// gsd-check-update.js — spawns its own background worker; no stdin
|
||||
runHook("gsd-check-update.js", {
|
||||
hook_event_name: "SessionStart",
|
||||
session_id: currentSessionId,
|
||||
cwd: currentCwd,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// file.edited → FileChanged hook (config.json reload)
|
||||
if (event.type === "file.edited") {
|
||||
// SDK type EventFileEdited: { properties: { file: string } }
|
||||
const filePath = event.properties?.file || event.filePath || "";
|
||||
if (!filePath.endsWith("config.json")) return;
|
||||
const cwd = event.properties?.cwd || currentCwd;
|
||||
const expected = path.join(cwd, ".planning", "config.json");
|
||||
if (path.resolve(filePath) !== path.resolve(expected)) return;
|
||||
|
||||
const payload = {
|
||||
hook_event_name: "FileChanged",
|
||||
file_path: filePath,
|
||||
event: "change",
|
||||
cwd,
|
||||
};
|
||||
const r = runHook("gsd-config-reload.js", payload);
|
||||
// Advisory-only (additionalContext); surface to logs
|
||||
handleHookResult(r);
|
||||
return;
|
||||
}
|
||||
|
||||
// session.idle ↔ Claude Stop lifecycle point (#1682 Slice 1b/c).
|
||||
// OpenCode fires session.idle when the run quiesces. GSD maps it to the
|
||||
// Stop equivalent — the opencode-subset lifecycle peer of compaction
|
||||
// (compaction preserves state across context-window summarization; idle
|
||||
// marks end-of-turn). No-op sentinel today (GSD state is already
|
||||
// persisted to .planning/), but it MUST be recognized so the declared
|
||||
// opencode-subset surface is fully wired and a future Stop-class hook can
|
||||
// attach without a plugin change.
|
||||
if (event.type === "session.idle") {
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Export shape — verified against OpenCode's plugin loader source
|
||||
// (packages/opencode/src/plugin). The loader imports this module and runs
|
||||
// `for (const entry of Object.values(mod)) { getServerPlugin(entry) }`, where
|
||||
// `getServerPlugin` accepts a bare function OR an object exposing a `.server`
|
||||
// function, and THROWS `TypeError("Plugin export is not a function")` for
|
||||
// anything else. So EVERY enumerable value the loader iterates must be a
|
||||
// function or an object with `.server`.
|
||||
//
|
||||
// The subtlety: depending on how OpenCode's runtime (Node or Bun) imports a
|
||||
// CommonJS file, `mod` may be the raw `module.exports` OR an ESM namespace of
|
||||
// the form `{ default: module.exports, ...syntheticNamedExports }`. A plain
|
||||
// `module.exports = { id: "gsd-core", server }` literal risks a string `id`
|
||||
// appearing in `Object.values(mod)` (as a raw property, or as a lexer-
|
||||
// synthesized named export) — which would trip the throw. Two defenses:
|
||||
// 1. `id` is defined NON-ENUMERABLE, so it never appears in Object.values yet
|
||||
// stays readable (via property access) for the loader's identity/dedup.
|
||||
// 2. `module.exports` is assigned from a VARIABLE (not an object literal), so
|
||||
// cjs-module-lexer cannot statically synthesize named exports from it —
|
||||
// only `default` is exposed under ESM/Bun interop.
|
||||
// Result: raw-CJS `Object.values` = `[server]`; ESM `Object.values` =
|
||||
// `[{server, <id non-enum>}]` — both fully extractable. Test-only helpers hang
|
||||
// off the `server` FUNCTION (`server._internals`), never as a sibling export.
|
||||
GsdCorePlugin._internals = {
|
||||
REPO_ROOT,
|
||||
IS_PACKAGE_TREE,
|
||||
mapToolName,
|
||||
mapToolInput,
|
||||
parseFrontmatter,
|
||||
rewriteContent,
|
||||
isGsdManagedFile,
|
||||
handleHookResult,
|
||||
GsdCorePlugin,
|
||||
};
|
||||
|
||||
const gsdCorePluginExport = { server: GsdCorePlugin };
|
||||
Object.defineProperty(gsdCorePluginExport, "id", {
|
||||
value: "gsd-core",
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
module.exports = gsdCorePluginExport;
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -6,6 +6,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.1] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Sonnet 5 is now the `standard` (sonnet) tier model.** The model catalog and provider presets resolve the sonnet/standard tier to `claude-sonnet-5` (GA 2026-06-30) across the Anthropic-backed runtimes (`claude`, `copilot`, and the `anthropic`/`anthropic-fable` presets), plus the OpenRouter-style `anthropic/claude-sonnet-5` for `opencode`/`hermes`, replacing the superseded `claude-sonnet-4-6`. Opus and Haiku tiers are unchanged. (#1847) (#1848)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `milestone complete` and `roadmap analyze` now exclude the Phase 0 / Phase 999 backlog sentinels. A milestone whose only directory-less ROADMAP heading is a backlog sentinel can be completed without `--force`, and `roadmap analyze` no longer counts the sentinel in `phase_count` or routes `next_phase` into it. Completes the `^999` exclusion #1445 added to the progress denominators. (#1691)
|
||||
- **`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.
|
||||
(#1819)
|
||||
- Windows: stop double-quoting $CLAUDE_PROJECT_DIR-anchored managed node hook paths during the #2979 legacy rewrite, which produced "\"$CLAUDE_PROJECT_DIR\"/..." and broke every node managed hook with MODULE_NOT_FOUND (PreToolUse-guard deadlock). (#1746)
|
||||
|
||||
## [1.6.0] - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
26
CONTEXT.md
26
CONTEXT.md
@@ -52,8 +52,8 @@ Module owning projection from dispatch results/errors to CLI `{ exitCode, stdout
|
||||
### STATE.md Document Module
|
||||
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 [Planned]
|
||||
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` (planned, generated from `src/state-transition.cts`).
|
||||
### STATE.md Transition Module
|
||||
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` 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)} | ||||