chore: merge release v1.7.0 to main (#2277)
Reconciles main (1.6.1) with the released 1.7.0 tree. main adopts the 1.7.0 tree; both parents retained so the 1.6.1 hotfix line stays in history. The 1.6.1 fixes (#1591/#1693/#1580/#1847) are already present in 1.7.0 via their own next-line PRs.
This commit is contained in:
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",
|
||||
"source": "./",
|
||||
"author": {
|
||||
"name": "open-gsd",
|
||||
"url": "https://github.com/open-gsd"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"displayName": "GSD Core",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.0",
|
||||
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"author": {
|
||||
"name": "open-gsd",
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -37,7 +37,6 @@ body:
|
||||
description: Which AI coding tool are you using GSD with?
|
||||
options:
|
||||
- Claude Code
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- Codex
|
||||
- Copilot
|
||||
@@ -174,7 +173,6 @@ body:
|
||||
|
||||
**How to retrieve:**
|
||||
- Claude Code: `cat ~/.claude/settings.json`
|
||||
- Gemini CLI: `cat ~/.gemini/settings.json`
|
||||
- OpenCode: `cat ~/.config/opencode/opencode.json` or `opencode.jsonc`
|
||||
|
||||
**⚠️ PII Warning:** This file may contain API keys, tokens, or custom paths. **Remove all API keys and tokens before pasting.** We recommend running through [presidio-anonymizer](https://microsoft.github.io/presidio/) or manually redacting any line containing "key", "token", or "secret".
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -170,7 +170,6 @@ body:
|
||||
description: Which runtimes must this work with? Check all that apply.
|
||||
options:
|
||||
- label: Claude Code
|
||||
- label: Gemini CLI
|
||||
- label: OpenCode
|
||||
- label: Codex
|
||||
- label: Copilot
|
||||
|
||||
105
.github/PULL_REQUEST_TEMPLATE/registry-entry.md
vendored
Normal file
105
.github/PULL_REQUEST_TEMPLATE/registry-entry.md
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
## Registry Entry PR
|
||||
|
||||
> **Using the wrong template?**
|
||||
> — Bug fix: use [fix.md](?template=fix.md)
|
||||
> — New feature (not a registry listing): use [feature.md](?template=feature.md)
|
||||
> — Enhancement to existing behavior: use [enhancement.md](?template=enhancement.md)
|
||||
|
||||
Full schema and process: [docs/registries/README.md](../../docs/registries/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Registry type
|
||||
|
||||
<!-- Check exactly one. -->
|
||||
|
||||
- [ ] Capability Registry entry — adds/updates one object in `docs/registries/capabilities.json`
|
||||
- [ ] EoS Registry entry — adds/updates one object in `docs/registries/eos.json`
|
||||
|
||||
## The entry
|
||||
|
||||
<!-- Paste the exact JSON object you added, unmodified. -->
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "",
|
||||
"name": "",
|
||||
"type": "",
|
||||
"repo": "",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"enginesGsd": "",
|
||||
"install": "",
|
||||
"uninstall": "",
|
||||
"interactions": {},
|
||||
"discussion": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required-field checklist
|
||||
|
||||
- [ ] `id`, `name`, `type`, `repo`, `description`, `author`, `license`, `enginesGsd`, `install`, `uninstall`, `interactions`, `discussion` are all present and non-empty
|
||||
- [ ] **(Capability entries only)** `interactions.loopExtensionPoints` is a non-empty subset of the 12 Loop Extension Points, `interactions.hookKinds` ⊆ `{step, contribution, gate}`, and `interactions.configKeys` / `requires` / `runtimeCompat` / `produces` / `consumes` are present (empty arrays are fine where nothing applies)
|
||||
- [ ] **(EoS entries only)** `protocolVersion` is an integer ≥ 1, `interactions.interfacePoints` is a non-empty subset of the six interface points, `interactions.profile` is one of `programmatic-cli` / `declarative-cli` / `ide`, and `interactions.axes` has exactly the eight required axis keys
|
||||
|
||||
## Ownership & non-endorsement
|
||||
|
||||
- [ ] `repo` links to a repository **I own or am the primary maintainer of** — not a fork, mirror, or someone else's project
|
||||
- [ ] I understand that inclusion in this registry means only that a maintainer merged this PR — it is **not** an endorsement, and GSD has not reviewed, tested, audited, or verified my solution or its claimed GSD interactions
|
||||
- [ ] I understand this entry is removed only for illegal content, malware, spam, or a dead/non-functional link — never for quality — and a maintainer may remove it on that narrow basis without further notice
|
||||
|
||||
## One entry, one PR
|
||||
|
||||
- [ ] This PR adds or updates exactly **one** entry, in exactly one of `capabilities.json` / `eos.json`
|
||||
- [ ] I have not bundled any other registry entry, code change, or unrelated docs change into this PR
|
||||
|
||||
## Generated file in sync
|
||||
|
||||
- [ ] I ran `npm run gen:registry` after editing the JSON source, and this PR includes the regenerated `docs/registries/capability-registry.md` or `docs/registries/eos-registry.md`
|
||||
- [ ] I did **not** hand-edit the generated `.md` file directly — all edits were made to the JSON source
|
||||
|
||||
## Documentation
|
||||
|
||||
> CI enforces `lint:docs` for any changeset fragment typed `Added` / `Changed` / `Deprecated` / `Removed` — it must also touch a file under `docs/`. The JSON source and its regenerated markdown, both under `docs/registries/`, satisfy this.
|
||||
|
||||
- [ ] This PR includes both the JSON source file and the regenerated markdown file under `docs/registries/`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `npm run validate:registry` passes locally against my entry
|
||||
- [ ] `discussion` links to a GitHub Discussion in the `Registry` category (or notes that one will be created on merge, per [docs/registries/README.md](../../docs/registries/README.md))
|
||||
- [ ] `.changeset/` fragment added with an `Added` type describing the new listing
|
||||
|
||||
---
|
||||
|
||||
## Example filled entry
|
||||
|
||||
<!-- Reference only — delete this section before submitting your PR. -->
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "linear-issue-sync",
|
||||
"name": "Linear Issue Sync",
|
||||
"type": "capability",
|
||||
"repo": "some-org/gsd-cap-linear-sync",
|
||||
"description": "Mirrors ROADMAP.md items to Linear issues as a ship:post contribution.",
|
||||
"author": "Some Org <hello@some-org.example>",
|
||||
"license": "MIT",
|
||||
"enginesGsd": ">=1.6.0",
|
||||
"install": "gsd capability install https://github.com/some-org/gsd-cap-linear-sync.git#v1.0.0",
|
||||
"uninstall": "gsd capability remove linear-issue-sync",
|
||||
"interactions": {
|
||||
"loopExtensionPoints": ["ship:post"],
|
||||
"hookKinds": ["contribution"],
|
||||
"configKeys": ["linear-issue-sync.enabled"],
|
||||
"requires": [],
|
||||
"runtimeCompat": ["all"],
|
||||
"produces": ["linear-issue-links"],
|
||||
"consumes": ["ROADMAP.md"]
|
||||
},
|
||||
"discussion": "https://github.com/open-gsd/gsd-core/discussions/1234"
|
||||
}
|
||||
```
|
||||
7
.github/workflows/auto-backmerge.yml
vendored
7
.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'
|
||||
DROPPED=$(printf '%s\n' "$DROPPED" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
case " $VERSION_STAMP_MANIFESTS " in
|
||||
@@ -141,11 +141,6 @@ jobs:
|
||||
echo "dropped_oneline=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
|
||||
- name: Install dependencies and build
|
||||
if: steps.check.outputs.next_exists == 'true'
|
||||
run: npm ci --silent && npm run build:lib
|
||||
|
||||
- name: Sync next's version to main's released version
|
||||
if: steps.check.outputs.next_exists == 'true'
|
||||
run: |
|
||||
|
||||
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:
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.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
|
||||
@@ -507,7 +507,10 @@ jobs:
|
||||
needs: [validate-version, install-smoke-finalize]
|
||||
if: inputs.action == 'finalize'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Matches the rc job's budget: `npm ci` + `npm run test:coverage:unit` now
|
||||
# exceeds 10m as the unit suite grows, so a 10m cap cancels the job mid-test
|
||||
# before tag/publish. 30m gives the same headroom rc already relies on.
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -67,6 +67,22 @@ build/
|
||||
# by `npm run build:lib`). Source of truth is src/; these are emitted, never edited.
|
||||
# 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/host-integration-adapters/imperative-hook-bus.cjs
|
||||
/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs
|
||||
/gsd-core/bin/lib/handshake-serialized.cjs
|
||||
/gsd-core/bin/lib/install-effort-resolver.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
|
||||
/gsd-core/bin/lib/capability-ledger.cjs
|
||||
@@ -75,6 +91,8 @@ build/
|
||||
/gsd-core/bin/lib/capability-consent.cjs
|
||||
/gsd-core/bin/lib/capability-lock.cjs
|
||||
/gsd-core/bin/lib/markdown-sectionizer.cjs
|
||||
/gsd-core/bin/lib/markdown-table.cjs
|
||||
/gsd-core/bin/lib/write-set.cjs
|
||||
/gsd-core/bin/lib/resolution.cjs
|
||||
/gsd-core/bin/lib/research-store.cjs
|
||||
/gsd-core/bin/lib/research-provider.cjs
|
||||
@@ -83,7 +101,9 @@ build/
|
||||
/gsd-core/bin/lib/plan-drift-guard.cjs
|
||||
/gsd-core/bin/lib/edge-probe.cjs
|
||||
/gsd-core/bin/lib/probe-core.cjs
|
||||
/gsd-core/bin/lib/spec-section.cjs
|
||||
/gsd-core/bin/lib/prohibition-enforcement.cjs
|
||||
/gsd-core/bin/lib/ui-consideration-probe.cjs
|
||||
/gsd-core/bin/lib/config-types.cjs
|
||||
/gsd-core/bin/lib/cli-exit.cjs
|
||||
/gsd-core/bin/lib/code-review-flags.cjs
|
||||
@@ -99,6 +119,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
|
||||
@@ -151,6 +172,7 @@ build/
|
||||
/gsd-core/bin/lib/core-utils.cjs
|
||||
/gsd-core/bin/lib/io.cjs
|
||||
/gsd-core/bin/lib/phase-id.cjs
|
||||
/gsd-core/bin/lib/normalize-test-command.cjs
|
||||
/gsd-core/bin/lib/config-loader.cjs
|
||||
/gsd-core/bin/lib/model-resolver.cjs
|
||||
/gsd-core/bin/lib/loop-resolver.cjs
|
||||
@@ -165,6 +187,7 @@ build/
|
||||
/gsd-core/bin/lib/phase-command-router.cjs
|
||||
/gsd-core/bin/lib/surface.cjs
|
||||
/gsd-core/bin/lib/gap-checker.cjs
|
||||
/gsd-core/bin/lib/gate-predicate-evaluator.cjs
|
||||
/gsd-core/bin/lib/docs.cjs
|
||||
/gsd-core/bin/lib/check-command-router.cjs
|
||||
/gsd-core/bin/lib/frontmatter.cjs
|
||||
@@ -177,6 +200,7 @@ build/
|
||||
/gsd-core/bin/lib/eval.cjs
|
||||
/gsd-core/bin/lib/eval-command-router.cjs
|
||||
/gsd-core/bin/lib/init-command-router.cjs
|
||||
/gsd-core/bin/lib/onboard-projection.cjs
|
||||
/gsd-core/bin/lib/agent-command-router.cjs
|
||||
/gsd-core/bin/lib/agent-install-check.cjs
|
||||
/gsd-core/bin/lib/task-command-router.cjs
|
||||
@@ -215,6 +239,7 @@ tmp/
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
.planning/.mempalace-stage/
|
||||
|
||||
# Local scratch + Claude-test artifacts
|
||||
.scratch/
|
||||
@@ -231,3 +256,7 @@ reports/mutation/
|
||||
# Local Crabbox machine configuration
|
||||
.crabbox.yaml
|
||||
.crabbox.local.yaml
|
||||
|
||||
# Memtrace local daemon/runtime state (per-machine; never committed)
|
||||
.memdb/
|
||||
.memtrace/
|
||||
|
||||
731
.kilo/plugins/gsd-core.js
Normal file
731
.kilo/plugins/gsd-core.js
Normal file
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// permission.asked / permission.replied — OpenCode permission lifecycle
|
||||
// (#2087, opencode.ai/docs/plugins). GSD gates tool INPUTS at
|
||||
// tool.execute.before (read-guard, injection-scanner); the permission
|
||||
// grant/deny decision itself carries no GSD workflow-phase contribution,
|
||||
// so these are recognized sentinels — wired so a future permission-aware
|
||||
// gate can attach without a plugin change (the engine owns phase
|
||||
// sequencing; this host bus is session/tool/permission-scoped, never
|
||||
// phase-scoped — ADR-1239 §OpenCode).
|
||||
if (event.type === "permission.asked" || event.type === "permission.replied") {
|
||||
return;
|
||||
}
|
||||
|
||||
// session.error — OpenCode session-error lifecycle point (#2087). No GSD
|
||||
// hook fires here today (loop state is already persisted to .planning/);
|
||||
// recognized so the declared extension-event surface is fully wired and a
|
||||
// future error-class hook can attach without a plugin change.
|
||||
if (event.type === "session.error") {
|
||||
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;
|
||||
731
.opencode/plugins/gsd-core.js
Normal file
731
.opencode/plugins/gsd-core.js
Normal file
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// permission.asked / permission.replied — OpenCode permission lifecycle
|
||||
// (#2087, opencode.ai/docs/plugins). GSD gates tool INPUTS at
|
||||
// tool.execute.before (read-guard, injection-scanner); the permission
|
||||
// grant/deny decision itself carries no GSD workflow-phase contribution,
|
||||
// so these are recognized sentinels — wired so a future permission-aware
|
||||
// gate can attach without a plugin change (the engine owns phase
|
||||
// sequencing; this host bus is session/tool/permission-scoped, never
|
||||
// phase-scoped — ADR-1239 §OpenCode).
|
||||
if (event.type === "permission.asked" || event.type === "permission.replied") {
|
||||
return;
|
||||
}
|
||||
|
||||
// session.error — OpenCode session-error lifecycle point (#2087). No GSD
|
||||
// hook fires here today (loop state is already persisted to .planning/);
|
||||
// recognized so the declared extension-event surface is fully wired and a
|
||||
// future error-class hook can attach without a plugin change.
|
||||
if (event.type === "session.error") {
|
||||
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;
|
||||
42
.out-of-scope/plan-md-execution-context-portability.md
Normal file
42
.out-of-scope/plan-md-execution-context-portability.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Clone-Portable `<execution_context>` in Committed PLAN.md
|
||||
|
||||
GSD does not make the `<execution_context>` block in committed `PLAN.md` files
|
||||
clone-portable — it does not rewrite the planner's install-relative
|
||||
`@…/gsd-core/…` references into repository-relative or install-neutral paths so
|
||||
that a committed plan reads identically across developers, machines, or runtimes.
|
||||
|
||||
## Why this is out of scope
|
||||
|
||||
PLAN.md is a **machine artifact**, not a human- or clone-facing document — the
|
||||
same principle that governs [plan-md-human-rendering.md](./plan-md-human-rendering.md)
|
||||
(from #2158). A committed PLAN.md is a per-run agent instruction set, produced by
|
||||
`gsd-planner` and consumed in place by `gsd-executor`, `gsd-plan-checker`, and
|
||||
`gsd-verifier`. There is no documented step in which a plan is read on another
|
||||
machine after `git clone` without a local GSD install.
|
||||
|
||||
Under that model, `<execution_context>`'s `@` references point at the reader's
|
||||
own local GSD install (Claude `~/.claude/gsd-core/…`, Cursor `.cursor/gsd-core/…`,
|
||||
or an absolute path for a `--local` install). They are install-relative by design,
|
||||
and the executor loads those workflows from its own installed copy — it never
|
||||
consumes the paths a *different* machine wrote into a committed plan.
|
||||
`/gsd-execute-phase` builds its own `<execution_context>` inline from the
|
||||
orchestrator's installed workflow (`workflows/execute-phase.md`), so the block a
|
||||
planner writes into a committed plan does not gate execution anywhere.
|
||||
|
||||
Making committed plans clone-portable would treat PLAN.md as a shared
|
||||
cross-developer document — the boundary #2158 declined to cross — for a block that
|
||||
no consumer reads across machines.
|
||||
|
||||
**Revisit if** GSD introduces a documented cross-developer / cross-machine contract
|
||||
for committed PLAN.md — a human- or teammate-facing use where plans are read after
|
||||
`git clone` without a local install — at which point `<execution_context>`
|
||||
portability becomes in scope.
|
||||
|
||||
## Prior requests
|
||||
|
||||
- #2238 — "Planner embeds machine-specific gsd-core paths in committed PLAN.md execution_context"
|
||||
|
||||
## Related
|
||||
|
||||
- `.out-of-scope/plan-md-human-rendering.md` — #2158, the governing "PLAN.md is a machine artifact" precedent.
|
||||
- `docs/reference/plan-md.md` — the `<execution_context>` reference, which describes the install-relative behaviour.
|
||||
63
.out-of-scope/plan-md-human-rendering.md
Normal file
63
.out-of-scope/plan-md-human-rendering.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Human-Readable Rendering of PLAN.md
|
||||
|
||||
GSD does not change PLAN.md's structural tag convention (`<task>`, `<action>`,
|
||||
`<tasks>`, `<files>`, `<verify>`, etc.) to improve how a PLAN.md renders when a
|
||||
human opens the raw file in a markdown viewer on GitHub/GitLab.
|
||||
|
||||
## Why this is out of scope
|
||||
|
||||
PLAN.md is a **machine artifact**, not a human-facing document. The docs are
|
||||
explicit:
|
||||
|
||||
- `docs/reference/plan-md.md` — a PLAN.md is *"an executable unit of work — a
|
||||
structured document that tells an executor agent exactly what to build and how
|
||||
to verify it was built correctly."*
|
||||
- `agents/gsd-planner.md` — *"Produce PLAN.md files that Claude executors can
|
||||
implement without interpretation. Plans are prompts, not documents that become
|
||||
prompts."*
|
||||
|
||||
PLAN.md is produced by `gsd-planner` and consumed by `gsd-executor`,
|
||||
`gsd-plan-checker`, `gsd-verifier`, and cross-AI review agents. There is no
|
||||
documented step in which a human opens, reads, reviews, or signs off on a
|
||||
PLAN.md — unlike `SUMMARY.md` / `VERIFICATION.md`, which are produced for human
|
||||
validation.
|
||||
|
||||
The reported symptom — alphabet-only tags like `<task>` / `<action>` tripping
|
||||
CommonMark's HTML-block rule so inner markdown renders as cramped run-on text —
|
||||
only manifests when a human views the raw file in a markdown renderer. It does
|
||||
**not** affect either machine consumer:
|
||||
|
||||
- Tag location/extraction is regex-based (`extractTaggedBlocks` in
|
||||
`src/markdown-sectionizer.cts`), operating on raw text, not rendered HTML.
|
||||
- Agents read the raw file content, not a rendered view.
|
||||
|
||||
```js
|
||||
// The extraction contract is a raw-text regex, indifferent to CommonMark
|
||||
// HTML-block folding:
|
||||
new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`, 'g')
|
||||
```
|
||||
|
||||
The proposed fixes (HTML-comment markers `<!-- task -->`, or underscored tag
|
||||
names `<task_node>`) would change a load-bearing machine convention that is
|
||||
duplicated across ~6 surfaces — the extractor regex, the `execute-plan` grep
|
||||
counter, `verify.cjs`, `decisions.cjs`, and the planner/executor schema docs. A
|
||||
drift between those surfaces silently breaks plan extraction (the executor finds
|
||||
zero tasks), which is a far worse failure than cosmetic rendering. The
|
||||
underscore option additionally increases token consumption on every plan read
|
||||
(longer tag names, repeated across every PLAN.md, read in full by the executor)
|
||||
and leaves the marker names visible as literal noise in any rendered view.
|
||||
Incurring that cost and risk to improve a rendering path that is not a
|
||||
documented use of PLAN.md does not align with the project's model of PLAN.md as
|
||||
an agent instruction set.
|
||||
|
||||
The same reasoning covers the report's secondary point (unquoted `|` in PLAN.md
|
||||
frontmatter breaking rendered markdown tables): that too is a human-render
|
||||
concern for a machine artifact.
|
||||
|
||||
**Revisit if** GSD ever introduces a human-review gate for PLAN.md — a step
|
||||
where a person reads and approves the plan before execution. At that point
|
||||
PLAN.md gains a documented human audience and its rendering becomes in-scope.
|
||||
|
||||
## Prior requests
|
||||
|
||||
- #2158 — "PLAN.md XML task tags trigger CommonMark HTML-block rule — task content renders as cramped run-on text"
|
||||
38
.out-of-scope/statusline-account-usage.md
Normal file
38
.out-of-scope/statusline-account-usage.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Statusline Account / Usage Segment (credential-reading, external API)
|
||||
|
||||
GSD's statusline does not read credentials or call external network APIs to
|
||||
display account-level resource state (5-hour / 7-day rate-limit utilization,
|
||||
usage windows, plan quotas).
|
||||
|
||||
## Why this is out of scope
|
||||
|
||||
The statusline draws its data boundary at **local, read-only** sources — see
|
||||
[`docs/adr/2164-statusline-scope-boundary.md`](../docs/adr/2164-statusline-scope-boundary.md).
|
||||
It refines the stdin payload Claude Code already sends (model, context meter,
|
||||
GSD-state) and may add a new *local* source (e.g. `git`), but it does not:
|
||||
|
||||
- read Claude Code's OAuth credentials (`.credentials.json`, or the macOS login
|
||||
Keychain via `security`), or
|
||||
- make authenticated network calls (e.g. `https://api.anthropic.com/api/oauth/usage`)
|
||||
to fetch data.
|
||||
|
||||
Reasons:
|
||||
|
||||
- **Trust surface.** A planning-workflow hook reading an OAuth token is a
|
||||
materially larger trust surface than any rendering concern — even read-only,
|
||||
never-logged, and opt-in. Credential custody belongs to the platform, not to
|
||||
a markdown planning tool.
|
||||
- **Unstable dependency.** The usage endpoint is undocumented; it can change or
|
||||
disappear and silently rot the feature.
|
||||
- **Scope.** Surfacing account/rate-limit state is a platform (Claude Code)
|
||||
concern. This matches the prior in
|
||||
[`temporal-context.md`](./temporal-context.md): *"Statusline / TUI re-entry is
|
||||
platform-level, not GSD-level."*
|
||||
|
||||
**Revisit if** a documented, first-party usage API — or a platform-provided
|
||||
value delivered to the hook without GSD reading credentials — becomes
|
||||
available. That would move usage display out of the excluded tier.
|
||||
|
||||
## Prior requests
|
||||
|
||||
- #2164 — "enhancement(statusline): opt-in 5-hour/7-day account usage segment"
|
||||
71
.pr-body-2100.md
Normal file
71
.pr-body-2100.md
Normal file
@@ -0,0 +1,71 @@
|
||||
## Linked Issue
|
||||
|
||||
Closes #2100
|
||||
|
||||
The linked issue carries the `approved-feature` label.
|
||||
|
||||
---
|
||||
|
||||
## Feature summary
|
||||
|
||||
Migrates **Windsurf** onto the ADR-1239 Embeddable Orchestration System — the largest of the EoS migrations. Folds all 10 residual `isWindsurf` branches onto the capability descriptor **and** wires GSD's write/command safety guards into Windsurf/Cascade's native blocking hook bus.
|
||||
|
||||
## What changed (highlights)
|
||||
|
||||
| File | What changed |
|
||||
|------|-------------|
|
||||
| `bin/install.js` | Folded 10 `isWindsurf` sites onto `hostBehaviors` (skipSharedHooksInstall, legacyDevinSkillsCleanup, installsCommandBodiesForWorkflowDelegation [#1629], verificationStyle); dropped 2 dead destructures + the dead `else if (isWindsurf)` agent arm; wired the Cascade hook-bridge install/uninstall; corrected stale comments |
|
||||
| `capabilities/windsurf/capability.json` | `hostBehaviors` block; `hooksSurface: "none"` → `"windsurf-hooks-json"` |
|
||||
| `src/runtime-hooks-surface.cts` | `writeWindsurfHooksJson`/`reconcileWindsurfHooksJson`/`removeWindsurfHooksJson` (Cursor-templated, Cascade's flat `{hooks:{<event>:[{command}]}}` shape) + event/script constants |
|
||||
| `hooks/gsd-windsurf-pre-write.js`, `gsd-windsurf-pre-command.js` | **New** Cascade-native blocking guard scripts (stdin JSON, exit-code-2 blocking) |
|
||||
| `gsd-core/bin/lib/capability-validator.cjs`, `src/runtime-config-adapter-registry.cts` | `windsurf-hooks-json` added to `VALID_HOOKS_SURFACES`, GATE A's `profile-marker-only` allowlist, and the `HooksSurface` union |
|
||||
| managed-hooks-registry / build-hooks / INVENTORY | registered the 2 new guard scripts |
|
||||
| tests / docs / changeset | `declarative-reference-windsurf` + `windsurf-hooks-bridge` (live blocking); matrix hookBus delta; changeset (`Changed`) |
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **Byte-parity concretely verified** (via the review): a real windsurf install rebuilt through the golden-parity harness → 327 files, 0 drift; only `windsurf.json` gains the 2 new script hashes. cursor/trae re-verified 0 drift. The load-bearing #1629 command-body copy (`.windsurf/gsd-core/commands/gsd/*.md` for local installs) is intact.
|
||||
- **The hook-bridge is faithful, not padding.** Cascade's `hooks.json` genuinely supports blocking via exit code 2 (confirmed against docs.windsurf.com / docs.devin.ai). Only **2 of GSD's 6 guards** faithfully map — the worktree-path guard (→ `pre_write_code`) and a destructive-command guard (→ `pre_run_command`). The 4 advisory guards + `pre_mcp_tool_use` + the 5 `post_*` logging events are **deliberately not wired**: Cascade's hook bus has no context-injection channel to carry GSD's advisory reminders faithfully, and GSD has no MCP-tool policy — porting them would be non-functional padding. This is the same faithful-subset pattern used for codebuddy #2098 / copilot #2099, and it satisfies AC4's testable requirement ("a real blocking hook rejecting a disallowed write/command").
|
||||
- **Security (reviewed, clean).** The guard scripts parse untrusted stdin and spawn `git rev-parse` — command injection via `file_path` was **refuted** (argv array, no `shell:true`, PATH-resolved git). No traversal / prototype-pollution (frozen 2-event set, fixed script names). The pre-command guard was **hardened post-review**: a tokenize-based classifier (no catastrophic-backtracking regex — a 200k-char pathological input now completes in ~32ms via a 4096-char cap), catching prefixed `rm -rf` forms (`sudo`/`env`/`/bin/rm`) and refspec force-pushes (`HEAD:main`, `+main`), and a fail-closed false-positive fixed (a `feature/main-fix` branch or a trailing-`# ...main` comment no longer wrongly blocks a legit force-push). Guards fail-open (never wedge Cascade) by design.
|
||||
- **Golden mechanics.** `.windsurf/hooks.json` is golden-excluded by basename (like settings.json); the 2 guard scripts under `hooks/` are windsurf-specific → only windsurf.json regenerates, additively.
|
||||
|
||||
## Spec compliance (acceptance criteria)
|
||||
|
||||
- [x] Golden parity: byte-identical for the folds across all 16 runtimes (windsurf.json regen is the additive hook-script delta only)
|
||||
- [x] Driven through the descriptor — zero live `runtime==='windsurf'`/`isWindsurf` branches (AC2 guard over 4 files)
|
||||
- [x] Every axis populated + `capability-validator`-clean (`runtime`/dispatch stay `undocumented` per the cited search trail)
|
||||
- [x] UPGRADE implemented AND exercised by a test driving a real blocking hook (exit-2 on a disallowed write/command)
|
||||
- [x] `negotiateHostCapabilities` fail-closes for windsurf (test)
|
||||
- [x] `gsd-test` green (linux node22/24); no other-runtime regression (cursor.json byte-identical)
|
||||
- [x] Docs (matrix hookBus delta) + changeset (`Changed`)
|
||||
|
||||
## Testing
|
||||
|
||||
- [x] macOS (real install byte-parity harness + live guard-script exit-2 probing + ReDoS timing)
|
||||
- [x] Windows (backslash; Windows destructive-command forms handled) — GitHub CI
|
||||
- [x] Linux (`gsd-test`)
|
||||
- [x] Runtimes: Windsurf (primary) + all 16 golden fixtures (only windsurf's 2 new scripts)
|
||||
|
||||
---
|
||||
|
||||
## Scope confirmation
|
||||
|
||||
- [x] Windsurf only; other runtimes byte-identical. The hook-bridge's faithful 2-guard scope (vs. the AC's fuller event list) is disclosed above — the unbridged events have no faithful GSD logic / Cascade channel.
|
||||
- [x] Cascade envelope/schema is best-effort per the official docs (guards fail-open if the live schema differs, never breaking Cascade); flagged for a live-Cascade schema confirmation follow-up.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [x] matrix (## windsurf hookBus/hooksSurface delta + the not-ported-guards rationale); English
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] `Closes #2100`; issue has `approved-feature`
|
||||
- [x] Acceptance criteria met (faithful hook-bridge scope disclosed)
|
||||
- [x] `gsd-test` green
|
||||
- [x] New tests cover the folds (AC2 guard) + the blocking hook bus (live exit-2) + fail-closed negotiation
|
||||
- [x] `.changeset/` fragment (`Changed`)
|
||||
- [x] No new dependencies
|
||||
|
||||
## Breaking changes
|
||||
|
||||
None at landing. New Windsurf install output is additive: 2 guard scripts + a `.windsurf/hooks.json` registering blocking pre-hooks. No skill, agent, workflow, or path is removed or altered; the guards fail-open.
|
||||
208
CHANGELOG.md
208
CHANGELOG.md
@@ -6,6 +6,214 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.7.0] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- **A default-off, BETA, claude-only "Claude orchestration" capability** — adopts Claude Code's Workflow tool (`/effort ultracode`, Agent SDK ≥ v0.3.149) as an optional parallel-execution backend for the GSD loop, restoring the wave parallelism + plan-checker + verifier that the #853 backgrounded-agent nesting limitation forces inline on Claude Code, and folding the existing `gsd-ultraplan-phase` plan-offload under the same runtime gate. When `claude_orchestration.enabled` is on AND the runtime is Claude AND the Workflow tool is detected AND the Agent SDK meets the floor (`claude_orchestration.min_agent_sdk_version`, default `0.3.149`), `execute-phase` emits a generated Workflow script (`waves → parallel() barriers`, `plans → agent({ agentType: 'gsd-executor', isolation: 'worktree' })`, `files_modified overlap → separate sequential stages`, `resumeFromRunId` wired to the phase run id, shared `budget` pool) that composes the SAME executor agent + worktree isolation the inline path uses, so artifacts/commits are produced identically. Detection is pure and fail-closed (any miss → inline), so on any runtime lacking the Workflow tool behaviour is byte-identical to today. Adds a pure module `gsd-core/bin/lib/claude-orchestration.cjs` (`detectWorkflowBackend`, `emitWorkflowScript`), the `capabilities/claude-orchestration/` declaration with two gated loop contributions (`execute:wave:post`, `plan:post`) and a `claude-orchestration` command family (`gsd-tools claude-orchestration detect-backend|emit-workflow`), federated config keys, and an ADR-1143 implementation amendment. (#1143) (#2044)
|
||||
- **Phases that integrate an external API/SDK/service can no longer seal without a decided coverage matrix** — a new `api-coverage` gate on the `ai-integration` capability blocks `/gsd:verify-work` until the phase produces a `COVERAGE.md` enumerating the API's full capability surface, with every non-integrated capability an explicit, reasoned opt-out. Full coverage is the default; the matrix is the subtraction record, so "we integrated the API" can no longer silently mean "we integrated whatever the first use case exercised." Toggleable via `workflow.api_coverage_gate` (on by default). (#1562) (#2065)
|
||||
-
|
||||
**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) (#1929)
|
||||
-
|
||||
**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) (#1930)
|
||||
- **GSD now warns when model config changed without re-running the installer on static-frontmatter runtimes** — on `codex` and `opencode`, editing `model_overrides` or `model_profile_overrides` or `model_policy.runtime_tiers` in `.planning/config.json` or `~/.gsd/defaults.json` previously had no effect until the user re-ran `gsd install <runtime>`, and the failure was silent: the sub-agent kept using the base model. Workflow entry points like `gsd-tools init *` now emit a one-line stderr warning naming the changed config file and the exact remediation command when they detect the config is newer than the baked agent files. The guard is read-only and warning-only by default, dedup'd per session, and skipped entirely on Claude Code because Claude Code resolves models at spawn time. Resolves #1688 as the structural follow-up to #1650. (#1692)
|
||||
- **`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`. (#1830)
|
||||
- **`graphify.graph_path` makes the knowledge-graph location configurable so one umbrella graph can serve multiple projects** — a new `.planning/config.json` key (path relative to project root, or absolute) overrides where `/gsd-graphify query|status|diff` read the graph, letting a single curated cross-repo umbrella graph serve every sibling sub-project without N drifting ~5 MB mirror copies. Previously the graph location was hardcoded to `<cwd>/.planning/graphs/` with no override; the only workaround was copying the umbrella `graph.json` into each project (which drifted, wasted disk, and could be silently overwritten by an in-project build). The diff snapshot travels with the configured graph; build stays project-scoped; unset → byte-identical default; a configured-but-missing file yields an actionable error naming the path. (#1825) (#2013)
|
||||
- **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) (#1848)
|
||||
- **Third-party capability gates now actually fire via a generic `command-exit-zero` predicate.** — a capability's declared `check.predicate` gate was rendered for display but never evaluated (only built-in `check.query` gates were enforced, and the `security` capability's gate worked solely via a hard-coded `ship.md` branch). A new generic evaluator (`gsd_run check predicate`) now evaluates `check.predicate` blocks by `kind`; the first built-in kind `command-exit-zero` runs a bounded `sh -c` command at the project root and blocks the loop on non-zero exit (timeout → block, fail-closed). The `execute:wave:post`, `execute:post`, and `plan:post` gate-dispatch sites route `predicate` gates to the new evaluator automatically. (#2008) (#2011)
|
||||
- **GSD's lifecycle hooks now run under Kimi CLI** — installing GSD into Kimi wires its session-state, phase-boundary, graphify, and guard hooks into Kimi's own native `config.toml` `[[hooks]]` bus (Beta on Kimi's side) instead of silently no-op'ing, and GSD's Kimi subagents can now run in the background. Kimi's install is driven by its negotiated capability descriptor instead of hardcoded runtime special-cases. (#2095) (#2159)
|
||||
- **GSD is now installable on pi** — `npx @opengsd/gsd-core --pi` installs the GSD extension to `~/.pi/agent/extensions/gsd.cjs`, and `/gsd <family> <subcommand>` now dispatches real commands through the embedded engine (the reference binding previously could only run `query help`). Drives pi through the negotiated imperative Host-Integration adapter, with active-model steering and the full pi lifecycle-event surface. (#2102) (#2205)
|
||||
-
|
||||
**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) (#1965)
|
||||
- `plan-phase` now authors edge and prohibition predicates into PLAN.md `must_haves` when a phase SPEC omits `## Edge Coverage` / `## Prohibitions`, so goal-backward verification still has predicates to check on a spec-less phase (ADR-857 Phase 6). Gated by the new default-on `workflow.specless_probe_fallback` toggle — disable it to skip the fallback (the skip is recorded visibly in the plan). Spec-less prohibitions are authored descriptor-less and disposed flagged/unverified (honest verifier #1154), never a silent pass. (#1835)
|
||||
- **Discover third-party GSD Capabilities in a new Community Capability Registry.** — A non-endorsing discoverability catalog where authors register a Capability via a documentation PR; each entry carries a live latest-release badge and a per-entry GitHub Discussion for community ranking and comments. (#2188) (#2188)
|
||||
- **GSD now warns when a stale global CLI (e.g. a retired @gsd-build/sdk canary) shadows your project-local install** — the gsd-tools CLI startup detects when the running binary is outside the project root while a project-local install exists, and prints a remediation warning to stderr (non-blocking). (#1754) (#1755)
|
||||
- **`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`. (#1810)
|
||||
- **Opt-in absolute token count on the statusline context meter** — new `statusline.show_context_tokens` config (default `false`). When enabled, the meter shows the absolute context total after the percentage, e.g. "████░░░░░░ 46% (156k)", summing input, cache-creation, cache-read, and output tokens from the hook payload (a broader basis than the meter's percentage, which is derived from `used_percentage` and excludes output tokens — the two figures can diverge slightly). Default meter output is unchanged. (#2161) (#2174)
|
||||
- **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) (#1998)
|
||||
-
|
||||
**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) (#1966)
|
||||
- **Discover third-party GSD Embeddable Orchestration System (EoS) integrations in a new EoS Registry.** — A non-endorsing discoverability catalog where host-integration authors register via a documentation PR; each entry declares its Host-Integration interface points, negotiated axes, and protocol version, with a live release badge and a per-entry GitHub Discussion for ranking and comments. (#2193) (#2193)
|
||||
- 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. (#1861)
|
||||
- **GSD now drives VS Code through the Embeddable Orchestration System** — the VS Code extension is rewired through the negotiated imperative Host-Integration adapter (active `vscode.lm` model, engine hook bus, sandboxed storage), gains native Language Model Tools (GSD skills as `#gsd-*` tools) and `#runSubagent` dispatch, and runs as a Web Extension (no Node APIs). (#2103) (#2210)
|
||||
- **`/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. (#1798)
|
||||
- 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. (#1923)
|
||||
- **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) (#1946)
|
||||
- **`/gsd-review` now supports custom reviewer instances** — run one model-capable adapter (e.g. OpenCode) as several independent reviewer identities via a bounded `review.reviewer_instances` config, so two different models can review in a single pass without manually swapping config or hand-merging REVIEWS.md. (#1517) (#1766)
|
||||
- **Opt-in git branch and working-state segment in the statusline** — the shell prompt's branch/dirty-state signal is hidden for the whole session under the Claude Code TUI, so wrong-branch commits and ship-time push rejections surface only after the fact. New `statusline.show_git` config (default `false`) renders the branch name plus staged/unstaged/untracked/ahead/behind markers (or ✓ when clean and in sync) after the directory segment. When disabled, no git subprocess is spawned and output is unchanged. (#2163) (#2183)
|
||||
- **`/gsd:onboard` guides brownfield setup** — existing repos now have a top-level onboarding command that routes through codebase mapping, docs ingest, project initialization, and an onboarding summary without silently overwriting planning files. (#1994)
|
||||
- **Plural/optional/chosen assumption-delta checkpoint during planning** — when a phase makes something plural, optional, or chosen that used to be singular, required, or derived, the planner is now prompted to re-ask whether the primary key / identity model still names the right thing, preventing silent architectural drift from accumulating into a later user-facing bug. Advisory (non-blocking); fires only on a detected signal. Toggle with workflow.assumption_delta. (#1561) (#1767)
|
||||
- **`/gsd-ui-phase` now probes UI state coverage** — a new `ui-consideration-probe` (the third `probe-core` adapter) enumerates the shape-rooted UI states a UI-SPEC must resolve (empty/loading/error/populated/partial/overflow/zero-one-many/long-text). After the UI checker approves, the probe surfaces applicable considerations for each element, records a `## UI Considerations` section in the UI-SPEC, and plan-phase lifts each resolved consideration into `must_haves` — so a purely-visual state with no wired test routes to `insufficient_spec → human_needed` at verify rather than a silent pass. (#1979)
|
||||
- **Host-Integration Interface (ADR-1239 Phase A)** — a versioned, negotiated capability contract (`runtime.hostIntegration`) over the six host-integration points (command, dispatch, model, hooks, state, artifact). Adds an in-process `negotiateHostCapabilities` handshake that fail-closes on undeclared/unknown/`undocumented` values (`effective ⊆ host-declared ∩ engine-known`), a typed degradation ladder, host-capability profiles, and a documentation-sourced per-CLI capability matrix for all 16 runtimes. Interface-definition only — no change to install behaviour. (#1690)
|
||||
- **ZCode (Z.ai) is now an installable runtime** — a desktop Agentic Development Environment for the GLM-5.2 model can now be targeted with `--zcode`, landing GSD skills at `~/.zcode/skills/<name>/SKILL.md` plus slash commands and subagents. ZCode ships as a pure declarative capability descriptor (`capabilities/zcode/capability.json`) with zero hardcoded `runtime === 'zcode'` branches, reusing the Claude skill converter — the de-hardcoded, data-driven runtime path that 1.7.0 (ADR-1016 / ADR-1239) enables. (#1925) (#2039)
|
||||
|
||||
### Changed
|
||||
|
||||
-
|
||||
**The GSD CLI now self-heals a missing runtime build.** The compiled `gsd-core/bin/lib/*.cjs` modules are gitignored build artifacts (ADR-457) that ship prebuilt in the npm tarball but are absent on a Claude Code plugin-marketplace / git-clone install, which never runs `npm run build:lib`. Previously every command died at load with `Cannot find module './lib/cli-exit.cjs'`. The `gsd-tools` entrypoint now detects the missing output and compiles it once, on demand (lock-guarded so parallel invocations don't race), then proceeds — a single no-op check on the already-built npm path. When TypeScript is genuinely unavailable it prints an actionable `npm install && npm run build:lib` message instead of crashing. (#2036)
|
||||
- **Internal: Claude Code's installer is now driven through the public Host-Integration Interface (ADR-1239 / EoS).** `bin/install.js` routes `claude` install/uninstall through the imperative adapter (`createImperativeAdapter`) instead of calling the engine directly, and its 13 hardcoded `runtime === 'claude'` / `runtime !== 'claude'` branches are folded into descriptor-driven `runtime.hostBehaviors` on `capabilities/claude/capability.json` (permission schema, `settings.local.json` scope routing, `.gsd-source` marker, effort frontmatter, canonical-workflow authorship, and more). Install/uninstall output is **byte-identical** for both the global skills layout and the local legacy layout (golden-parity asserted for both scopes); no other runtime changes. Removes the "add-a-host tax" of scattered string-equality checks for the tier-1 reference host. No user-facing change. (#2086) (#2106)
|
||||
- **OpenCode is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** OpenCode and its Kilo sibling previously installed via a bespoke `runtime === 'opencode'`/`isOpencode` branch in `bin/install.js`; its commands+skills+plugin install now runs through the imperative adapter → the engine's combined-family install path (`installRuntimeArtifacts`), and every hardcoded `runtime === 'opencode'` branch is folded into descriptor-driven `runtime.hostBehaviors`. Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **background dispatch** — OpenCode shipped experimental background subagents in v1.15 and made them default-on in v1.17, so `dispatch.background`/`backgroundDispatch` flip to `true`; GSD no longer force-flattens OpenCode-hosted wave dispatch (`shouldFlattenDispatch` now returns `false`), letting agents run concurrently where the host supports it. (2) **expanded event surface** — the OpenCode plugin now subscribes to `permission.asked`, `permission.replied`, and `session.error` (added to `EXTENSION_EVENT_SURFACES.opencode`), wiring the declared surface for future permission/error-aware bindings. (#2087) (#2108)
|
||||
- **Codex is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Codex previously installed via hardcoded `runtime === 'codex'`/`isCodex` projection in `bin/install.js`; its `config.toml` / agent-`.toml` / `hooks.json` install now runs through the declarative embedding adapter and descriptor-driven `runtime.hostBehaviors`, with **zero** positive `isCodex` gates and **zero** `runtime === 'codex'` branches remaining (source-guarded). Install/uninstall output stays byte-parity-gated (`tests/fixtures/golden-install-parity/codex.json`). Three Context7-verified upgrades land, each with a test driving the user-reachable surface: (1) **skill root** — GSD skills now install to Codex's canonical `$HOME/.agents/skills` (via a skills-kind `home` override) instead of the deprecated `$CODEX_HOME/skills` fallback, and pre-move installs are migrated (stale `~/.codex/skills/gsd-*` cleaned on both install and uninstall, user-owned content preserved); (2) **hook events** — GSD registers the six documented Codex lifecycle events it previously skipped (`PreToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStop`, `UserPromptSubmit`, in addition to the existing `SessionStart`/`SubagentStart`/`Stop`/`PostToolUse`) in `hooks.json`, so `gsd-context-monitor` fires at the same points as in Claude Code, and the descriptor `extendedHookEvents` is reconciled from `[]` to the schema-valid wired subset; (3) **dispatch tuning** — `[agents] max_depth = 1` is written explicitly into the managed `config.toml` block to pin the negotiated `dispatch.maxDepth: 1` axis (`degradationFor` flattens GSD-hosted waves to single-level), and `validateCodexConfigSchema` now permits a known-scalar-only `[agents]` AgentsToml table (coexisting with the flattened `[agents.gsd-*]` role sub-tables) while still rejecting the `[[agents]]` and unknown-key break-forms from #2760. (#2088) (#2110)
|
||||
- **Cursor is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cursor previously installed via hardcoded `runtime === 'cursor'`/`isCursor` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cursor branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, hooksJsonSurface, skipSharedHooksInstall, reportCommandsDir, managedHookEvents). Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in Cursor's `hooks.json` (`preToolUse`, `stop`, `subagentStart`, `subagentStop` in addition to the original `sessionStart`/`postToolUse`), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/imperative-hook-bus.cts`) that reads `hostBehaviors.managedHookEvents` instead of a hardcoded event pair; cite https://cursor.com/docs/hooks. (2) **named/background nested subagent dispatch** — Cursor's `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`, so `shouldFlattenDispatch(cursor)` returns `false` and GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent invocation instead of flattening to inline sequential calls; cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript. (#2089) (#2120)
|
||||
- **Cline is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cline previously installed via hardcoded `runtime === 'cline'`/`isCline` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cline branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot, clineRulesSurface, localCommandsViaRules). Install/uninstall output is **byte-identical** (golden parity asserted for cline + claude/cursor/codex/opencode). Two Context7-verified upgrades land: (1) **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` that cancels write-class calls targeting `.planning/` (same fail-open semantics), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/cline-sdk-binding.cts`); cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx. (2) **`createAgentModel` model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's per-subagent `model_overrides`/`model_profile_overrides` resolution applies to Cline subagents (`modelMode: active`); cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx. Cline's dispatch deliberately stays **degraded/flat** (`maxDepth: 1`, read-only, no nested spawning) per the documented host restriction — never silently upgraded to full nested/background. (#2090) (#2132)
|
||||
- **Hermes Agent is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Hermes previously installed via hardcoded `runtime === 'hermes'`/`isHermes` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded hermes branch is folded into descriptor-driven `runtime.hostBehaviors`. Three upgrades land: (1) **real plugin hook vocabulary** — GSD registers a new `extensionEvents: "hermes"` dialect carrying the 13 documented Hermes plugin events (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `pre_approval_request`, `transform_tool_result`), replacing the borrowed `hookEvents: "claude"` 6-event surface that silently never fired; cite https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md. (2) **dispatch posture** — Hermes' `dispatch.nested: true` with `maxDepth: 1` is correctly negotiated (not silently flattened). (3) **branding/category metadata** — `DESCRIPTION.md` category descriptions, `version:` frontmatter, and branding rewrites are now descriptor-driven rather than hardcoded. Install/uninstall output is byte-identical (golden parity asserted for all runtimes). (#2091) (#2134)
|
||||
- **Qwen Code now projects GSD's specialist agents as native subagents** — installing GSD into Qwen Code writes `~/.qwen/agents/gsd-*.md` files you can invoke directly (planner, executor, code-reviewer, …) instead of reaching them only through skill prose, and a `SubagentStart` hook now fires alongside `SubagentStop`. Qwen's install is driven by its negotiated capability descriptor instead of hardcoded runtime special-cases. (#2092) (#2153)
|
||||
- **Kilo Code now supports native hooks, active-model routing, and named subagent dispatch** — installing GSD into Kilo wires a lifecycle-hook plugin, keeps each agent's requested model instead of dropping it, projects GSD's specialist agents as invokable subagents, and documents the GSD MCP companion. Kilo's install is driven by its negotiated capability descriptor instead of hardcoded runtime special-cases. (#2093) (#2156)
|
||||
- **GSD skills installed for Trae now carry SOLO stage metadata** — Trae's SOLO Agent can recognize GSD skills as workflow-stage skills for auto-invocation instead of requiring manual triggering. Several of Trae's install branches (shared-hooks gating, path rewrites) also move onto its capability descriptor. Note: the stage-metadata field is a best-effort/inferred shape — Trae publishes no formal schema. (#2094) (#2157)
|
||||
- **Installing GSD into Antigravity now writes the `permissions.allow` rules its CLI documents** — so GSD's own reads and hooks aren't stuck on interactive prompts — and registers GSD's companion MCP server via a standalone `mcp_config.json` (best-effort: Antigravity's raw config schema isn't published, so this uses the Gemini-CLI-successor format). Antigravity's install is now driven by its negotiated capability descriptor instead of hardcoded runtime special-cases. (#2096) (#2165)
|
||||
-
|
||||
**Augment Code now installs through its capability descriptor, with a native MCP companion** — installing GSD into Augment registers the GSD companion server in Augment's `settings.json` `mcpServers` and drives command/skill/agent conversion from Augment's negotiated descriptor instead of hardcoded runtime special-cases. (#2097) (#2166)
|
||||
-
|
||||
**CodeBuddy now wires GSD's full extended lifecycle hook set and is driven by its capability descriptor** — installing GSD into CodeBuddy now registers `SubagentStart`, `SubagentStop`, `Stop`, and `PreCompact` hooks in its `settings.json` (it previously had none of these), matching the coverage Qwen/Kimi already ship, and CodeBuddy's install is fully descriptor-driven instead of via residual hardcoded runtime branches. (#2098) (#2169)
|
||||
-
|
||||
**GitHub Copilot now wires GSD's full lifecycle hook bus and is driven by its capability descriptor** — installing GSD into Copilot registers `preToolUse`, `postToolUse`, `userPromptSubmitted`, and `sessionEnd` handlers in its `hooks/gsd-session.json` (beyond today's `sessionStart`-only advisory), and Copilot's residual hardcoded runtime branches are folded onto descriptor-driven `hostBehaviors`. (#2099) (#2172)
|
||||
- **Windsurf now enforces GSD's write/command safety guards through Cascade's native hook bus** — installing GSD into Windsurf registers blocking `pre_write_code`/`pre_run_command` hooks in `.windsurf/hooks.json` (exit-code-2 blocking) and drives Windsurf's install from its capability descriptor instead of hardcoded runtime branches. (#2100) (#2190)
|
||||
- **ZCode's install is now driven and regression-tested through its capability descriptor** — ZCode joins the dogfooded declarative-adapter reference hosts with a byte-identical install, and its shared-hooks exclusion is folded onto `hostBehaviors` instead of a hardcoded runtime branch. (Hook-automation and MCP upgrades remain blocked on ZCode publishing its on-disk config formats.) (#2101) (#2195)
|
||||
- **Codex/OpenAI default models advance to the GPT-5.6 family (Sol/Terra/Luna)** — the Codex runtime tier defaults and the `openai` provider preset now resolve to current-generation model IDs instead of the superseded GPT-5.4/5.5 line, so Codex users on default profiles get improved agentic coding (Sol) and lower costs (Terra/Luna) without changing any config. (#2122) (#2146)
|
||||
- **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. (#1813)
|
||||
- **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. (#1811)
|
||||
- **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. (#1808)
|
||||
- **Internal: agent install for cursor/windsurf/augment/trae/codebuddy now flows through the descriptor path** — ADR-1235 step 1 routes the trivial-converter runtime group's agents off the inline install() loop onto the descriptor-driven `installRuntimeArtifacts` path, applying the cross-cutting steps uniformly (pre-converter, no workflow-stamp). Agent output is byte-identical for all 16 runtimes (golden-parity asserted, global + local verified); no user-facing change. (#1764)
|
||||
- **gsd-ui-checker gains an adversarial FORCE stance (LLM-playbook principle 16)** — the only verdict-producing critic that lacked one now resists rubber-stamping UI-SPEC contracts, with BLOCK/FLAG/PASS classification. Based on arXiv 2505.23840 (third-person objective persona), 2506.04975 (objective-not-hostile persona). (#1584)
|
||||
- **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. (#1802)
|
||||
- **Internal: getDirName is now derived from a documented `runtime.localConfigDir` descriptor field** — each runtime's local content-rewrite directory (e.g. `cursor`→`.cursor`, `copilot`→`.github`) moved from a hand-maintained if-chain into its capability descriptor (ADR-1239 Phase B), so it can no longer drift from the registry. Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change. (#1757)
|
||||
- **Internal: copyWithPathReplacement converter selection is now data-driven** — the installer's back-compat content-copy path replaced its 13 hardcoded `runtime === 'x'` flag chains with a single per-runtime dispatch table (ADR-1239 Phase B). Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change. (#1759)
|
||||
- **Phase-completion now writes `Status: All phases complete` instead of the overloaded bare `Milestone complete`** — the phase-level completion verb (`completePhaseCore`) was writing the same bare 'Milestone complete' string that the milestone-close verb uses for terminal state, causing a phase-level verb to own a milestone-level field. Per ADR-2207, phase-completion now writes the existing intermediate value 'All phases complete' (already used in gsd2-import.cts); milestone termination ('<version> milestone complete' / 'Awaiting next milestone') remains solely with the milestone-close verb. (#2204) (#2259)
|
||||
- **#853 dispatch-flatten is now data-driven (ADR-1239 Phase B)** — whether GSD backgrounds the plan/execute orchestrator is decided from a documentation-sourced `backgroundDispatch` capability per host (via `gsd_run query dispatch-should-flatten`) instead of a hardcoded `runtime === 'codex'` check. **Cursor now backgrounds the orchestrator** (its docs document backgrounded subagent nesting); codex unchanged; all other hosts run inline. Fail-closed to inline on any uncertainty. (#1719)
|
||||
- **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. (#1809)
|
||||
- **`requirements mark-complete` reports a per-surface write-set** — the command now returns a per-requirement `write_set` (checkbox + traceability surfaces) and a `write_set_complete` that is true only when every surface of every requirement applied, so a partial (checkbox-only) reconcile can no longer masquerade as full success even inside a multi-ID batch. Introduces the reusable ADR-2143 §5/§6 `Result` / `WriteSet` contract. (#2251) (#2251)
|
||||
- **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. (#1803)
|
||||
- **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. (#1804)
|
||||
- **Internal: derive the non-Claude runtime list from the capability registry** — `NON_CLAUDE_RUNTIMES` is now computed from the capability registry instead of a hand-maintained literal, so it can no longer drift from the per-runtime descriptors. No user-visible behavior change (the list is identical). (#1728)
|
||||
- **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). (#1738)
|
||||
- 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. (#1922)
|
||||
- **Extraction discipline for strict-format agents (LLM-playbook principle 8)** — gsd-doc-classifier and gsd-doc-synthesizer apply taxonomy/precedence rules directly without inventing content, reducing reasoning-induced format drift. Based on arXiv 2504.05081 (few-shot beats CoT for pattern tasks), 2506.00069 (terminal instruction placement), 2505.14810, 2505.11423. (#1584)
|
||||
- **Internal: extracted the runtime-artifact install engine from `bin/install.js`** — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` and their helpers now live in a dedicated `gsd-core/bin/lib/install-engine.cjs` module (ADR-1239 Phase B), so adapters can import the install pipeline instead of reaching into the 12k-line installer. Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing behaviour change. (#1735)
|
||||
- **MemPalace `memory_mode` `kg_backend` and `replace` are now functional** — selecting either mode now routes recall through the palace instead of silently behaving like `augment`: `kg_backend` treats the palace temporal KG as the primary knowledge-graph source (native `.planning/graphs/` as fallback), and `replace` resolves recall through the palace as the source of truth. Every mode stays default-resilient — an unreachable palace falls back to native memory and no memory is lost. (#2010) (#2010)
|
||||
- **`/gsd:surface` and `--materialize` now produce byte-identical agent output to a fresh install** — surface-path agents for descriptor-driven runtimes (cursor, windsurf, augment, trae, codebuddy, copilot, antigravity) now receive the same path-prefix rewrite, Co-Authored-By attribution, runtime-specific conversion, and body normalization as the install path. Copilot and Antigravity agents are now installed via the descriptor-driven path (copilot agents get the `.agent.md` filename rename). Cline remains on the inline loop (rules-only local branch). (#1575) (#2040)
|
||||
- **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. (#1805)
|
||||
- **Long-context model names render compactly in the statusline** — the verbose " (1M context)" suffix Claude Code appends to the model display name now collapses to a compact " (1M)" badge (tolerant of future window sizes and the abbreviated "ctx" variant: "(500K context)" → "(500K)", "(1M ctx)" → "(1M)"). Lossless — the long-context signal stays, the 12 characters of width don't. (#2160) (#2173)
|
||||
- **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) (#1934)
|
||||
- **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) (#1868)
|
||||
- **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'. (#1800)
|
||||
- **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. (#1806)
|
||||
- **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. (#1801)
|
||||
|
||||
### Removed
|
||||
|
||||
- **Removed the sunset Gemini CLI runtime — use Antigravity CLI instead** — Google discontinued Gemini CLI on 2026-06-18, so `npx gsd-core --gemini` now prints a deprecation notice and points you to Antigravity CLI (the official successor), which GSD already ships as a first-class runtime. (#1928) (#1996)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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. (#1687)
|
||||
- `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)
|
||||
- **`config-set` no longer silently coerces values into something the disk never sees** — `Number.isFinite` replaced `!isNaN` in the value parser so `Infinity`/`-Infinity` are no longer coerced to non-finite numbers that `JSON.stringify` then renders as `null` on disk while the CLI echoes `Infinity` (output ≠ disk). `context_window` now has a per-key validator requiring a finite positive integer (rejects `Infinity`, `0`, negatives, non-integers with a non-zero exit), and `project_code` is always persisted as a string so a leading-zero code like `007` survives verbatim instead of collapsing to `7`. Numeric coercion for genuine numeric keys (e.g. `granularity 42`) is unchanged. (#1581) (#2023)
|
||||
- **`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.
|
||||
(#1819)
|
||||
- The `<agent_skills>` block emitted by `gsd init` no longer leaks backslash paths into `@`-reference skill paths on Windows. The global skill directory (a native `path.join` result) was interpolated into the generated markdown without POSIX normalization, producing references like `@C:\…\skills\name/SKILL.md`; the reference is now normalized at the emit site so skill references use forward slashes on every platform. (#1736)
|
||||
- **`/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. (#1814)
|
||||
- **`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. (#1818)
|
||||
- **`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. (#1815)
|
||||
- **`/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>`). (#1816)
|
||||
- **The installer no longer copies dead lifecycle hook scripts for Kilo and ZCode** — both declare `hooksSurface: 'none'` and have no plugin surface, so the staged `hooks/*.js`, `hooks/*.sh`, `hooks/lib/` and the CommonJS `package.json` marker were dead weight in `~/.kilo/` and `~/.zcode/`. The two hook-copy guards in `install.js` now exclude Kilo and ZCode alongside the other no-hook runtimes. OpenCode, which also declares `hooksSurface: 'none'`, is deliberately kept: its native plugin adapter (#1914) spawns those staged hooks via OpenCode's event bus and needs both them and the marker. (#2057)
|
||||
- **Test gates can no longer hang forever on a watch-mode test runner.** vitest defaults to watch mode in an interactive terminal (exactly where `gsd-execute-phase` runs), so a resolved `npm test` / `pnpm test` that maps to vitest never exited and the orchestrator waited indefinitely until the user manually intervened. Every GSD test-command gate — the regression gate, the post-merge gate, the audit-fix gate, and the verify-phase gate — now routes the resolved command through a shared `normalize-test-command` helper that rewrites it to a one-shot form (direct vitest → `vitest run`; jest `--watch` → `--watchAll=false`; a package-manager `test` script backed by watch-vitest → `CI=true` prefix; already-one-shot commands are left unchanged). The three gates that previously hung or silently continued — the regression, post-merge, and audit-fix gates — additionally bound execution with a configurable `workflow.test_gate_timeout` (default 600s), aborting or surfacing the cause on timeout instead of hanging; the verify-phase gate was already bounded (a fixed 5-minute limit) and keeps it, now naming watch mode on timeout. The normalizer only rewrites a runner named as a standalone command token (so paths/targets like `run-vitest.js` are never mangled), is length-capped and linear-time on adversarial input, and only reads a regular-file `package.json`. (#2060)
|
||||
- **`settings-advanced.md` no longer has an orphan `</step>` around §8 Model Policy** — the §8 Model Policy block ended with a closing `</step>` but had no matching opening tag (5 opens / 6 closes), leaving its content as loose inter-step prose that could fail to execute reliably. Added the missing `<step name="model_policy">` opener so the section is a proper step. A new workflow `<step>`-tag-balance regression guard (fenced-code-stripped) now blocks any future orphan tag across all top-level workflows. (#1864) (#2014)
|
||||
- **The runtime launcher now honors `CLAUDE_CONFIG_DIR`** — the `gsd_run` preamble embedded in every workflow/agent resolved the Claude global install only at `$HOME/.claude/gsd-core/bin/`, while the installer honored `CLAUDE_CONFIG_DIR`, so a global install redirected via `CLAUDE_CONFIG_DIR` was invisible to every `gsd_run` call (every GSD command failed with `gsd-tools.cjs not found`). The Claude resolver arm now uses `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` — matching the installer and the other runtimes' `${VAR:-default}` pattern — so a custom `CLAUDE_CONFIG_DIR` is found and the default `$HOME/.claude` path is unchanged. Re-synced into all 95 workflows/agents; two capped workflows trimmed to stay under their byte budgets. (#1865) (#2024)
|
||||
- **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) (#2001)
|
||||
- **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. (#1938)
|
||||
- **`/gsd:verify-work` preserves verification state across gap-closure execution and no longer auto-promotes deferred follow-ups into blocking gaps** — resuming after `/gsd:execute-phase --gaps-only` used to lose the verification state: the UAT `## Gaps` still read `status: failed` even after their fix plans executed, so verify-work re-diagnosed them as fresh blockers, spawned a new gap plan, and reported only the new plan as verified. A state contract now links each gap to its fix plan: every UAT gap carries a stable `gap_id` (`G-{phase}-{N}`), gap-closure plans tag the ids they address in their frontmatter (`gap_ids: […]`), and a new `reconcile_gaps` step on resume marks a gap `status: resolved` when its plan has a matching `*-SUMMARY.md` — so fixed gaps aren't re-diagnosed and the phase can close. Separately, a deferred-follow-up branch captures future-work ideas (signals like "later", "next version", "out of scope") into a `## Deferred Follow-Ups` section instead of creating a blocking gap/plan. (#1921) (#2025)
|
||||
- **`roadmap update-plan-progress` no longer counts stray non-plan `*-SUMMARY.md` files against phase completion** — remediation/gap-closure summaries (e.g. `30-FIX-CR02-SUMMARY.md`, `30-GAPCLOSURE-SUMMARY.md`) inflated `summary_count`, and once `summary_count >= plan_count` the phase silently flipped to `Complete` (checkbox checked, date stamped) even though several plans had no summary. A new `countMatchedSummaries` helper (core-utils) pairs summaries to plans via the `PLAN→SUMMARY` marker swap + the `<stem>-SUMMARY.md` form (layout-agnostic across root, bare, and nested layouts), so only a summary that corresponds to a real plan counts. Wired into `scanPhasePlans` (fixing roadmap listing, state sync, verification, workstream inventory at once) and `cmdRoadmapUpdatePlanProgress`. (#1988) (#2016)
|
||||
- **`milestone complete --ws` requirements archive header now points at the workstream REQUIREMENTS.md** — the archive header string hardcoded the root path (`` `…see .planning/REQUIREMENTS.md` ``), so a workstream archive directed readers at the wrong file even though #1917 had already fixed the archive *locations* to land inside the workstream. The display path is now derived from the same workstream-aware `reqPath` the writer uses (`path.relative(cwd, reqPath)`), so root behavior is byte-identical and the workstream case correctly reads `.planning/workstreams/<ws>/REQUIREMENTS.md`. (#1993) (#2015)
|
||||
- **Load-failed capability gates now fail open with a loud warning instead of blocking the whole project** — when an installed overlay (third-party) capability failed to load (e.g. an incompatible `engines.gsd` range) but had declared a `gate`-kind loop hook, the loop resolver injected a blocking synthetic gate (`blocking:true`, `onError:halt`) at every point where that capability declared a gate. A single incompatible capability therefore halted every `ship:pre` and `verify:post` in the project — unrelated to what the gate would have checked, and with no remediation surfaced. The resolver now injects no gate and instead emits a loud warning — to stderr and in the `loop render-hooks` envelope's `warnings` array — naming the load-failure reason and the exact `gsd capability remove <id>` remediation, and the loop proceeds (fail open). The capability id embedded in that remediation is validated against the canonical id shape first, so a malformed overlay directory name cannot inject shell metacharacters into the surfaced command. The loader still records `_overlay.blockedGates`; only the consequence changes from block to warn. `step`/`contribution` overlays were already skip-open. (#2009) (#2075)
|
||||
- **`phase.complete` now updates the `## Progress` rollup row even when an earlier phase-numbered table precedes it** — the Progress-row writer used a non-global regex that matched *any* table row starting with the phase number, so it bound to the first such row (e.g. a `| Phase | Requirements | Count |` coverage table), no-op'd on the wrong 3-column row, and never reached the real Progress row. The regex is now scoped to the `## Progress` section so it binds to the correct table. The command still returned `roadmap_updated: true` (that field is `fs.existsSync(ROADMAP.md)`), masking the silent failure. (#2012) (#2032)
|
||||
- **context7 now works for plugin-marketplace installs (8 agents regained doc lookup)** — the agents granted only `mcp__context7__*`, which matches a standalone context7 MCP server but not the official Claude Code plugin-marketplace install (`context7@claude-plugins-official`), whose tools are named `mcp__plugin_context7_context7__*`. The grant never matched, so advisor/ai/domain/phase/project/ui-researcher + planner + executor silently lost documentation lookup and fell back to WebSearch. All 8 agents now grant both forms, the researcher profile table is updated, and a parity guard asserts no agent grants the standalone form without the plugin form. (#2017) (#2029)
|
||||
- **`applySurface` no longer deletes every `gsd-*` agent when the skills manifest resolves empty** — the agent-prune loop in `_syncGsdDir` deleted any `gsd-*.md` not in the staged set, and when the manifest was empty/unresolvable (null manifest, no array entries, no `files` key, or an unresolvable install source root), the staged set was empty → every agent was pruned. Skills were guarded by `pruneSkillDirs`'s manifest-membership check (conservative preservation on empty manifest); agents had no equivalent. The agent-prune loop is now skipped when the manifest is empty/absent, so agents are preserved while copy (adding genuinely new agents) still runs. (#2018) (#2031)
|
||||
- **`planning-config.md` global-learnings path corrected to `~/.gsd/knowledge/`** — the `features.global_learnings` row directed users to `~/.gsd/learnings/`, but the implementation (`src/learnings.cts`, `execute-phase.md`) stores and reads global learnings from `~/.gsd/knowledge/`. Anyone following the docs to inspect, back up, or seed their global learnings looked in a directory the code never touches. (#2019) (#2026)
|
||||
- **Removed dead SDK file references from runtime-loaded markdown that triggered an infinite `find.exe` storm on Windows** — `agents/gsd-executor.md` pointed at `sdk/src/query/QUERY-HANDLERS.md` and `gsd-core/workflows/reapply-patches.md` at `sdk/dist/cli.js`, both retired with the SDK package (ADR-0174). AI runtimes that resolve doc references by filesystem search ran `find / -iname …`; on Git Bash for Windows `/` maps to the drive root, so `find.exe` traversed the whole disk (14h+, orphaned processes, 4M+ open handles each, unkillable). The references now resolve to live paths, and a new regression guard asserts no `sdk/src|sdk/dist|sdk/handlers` file references remain in agents/workflows/references markdown. (#2020) (#2027)
|
||||
- **`roadmap update-plan-progress` no longer checks the phase checkbox without verification** — the command stamped the phase-level ROADMAP checkbox and completion date the moment the last plan summary landed (called routinely after every wave and every plan), with **no verification gate** — unlike `phase.complete` which correctly requires `readVerificationStatus(...).status === 'passed'`. Now `isComplete` requires both all plan summaries AND a passed verification, matching the `cmdPhaseComplete` contract, so the checkbox only fires after `gsd-verifier` has confirmed the phase. (#2022) (#2030)
|
||||
- **`phase complete` no longer marks a milestone done out of order, nor silently writes root state in workstream mode.** Completing the numerically-highest phase while an earlier phase was still outstanding wrongly flipped STATE.md to `Status: Milestone complete` (the milestone-end check only looked for higher-numbered phases, so an out-of-order completion — e.g. Phase 10 before Phase 9 — read as the end). It now reports milestone-end only when every lower-numbered phase in the milestone is checked complete. Separately, in workstream mode with no active workstream, `phase complete` previously fell back to root `.planning` and wrote STATE.md/ROADMAP.md (and the mislabel) into the shared root other workstreams read; it now fails safe — asking for `--ws <name>` or an active workstream — mirroring the existing `init progress` guard. (#2066) (#2066)
|
||||
- **Phase directories whose slug begins with a single digit now resolve correctly.** A phase like `46-6-rs-pipeline-orchestrator` (roadmap name "6 Rs Pipeline Orchestrator") had its phase token over-collected as `46-6` instead of `46`, so `gsd-tools` phase-by-number lookups resolved `phase_dir=null` / `has_context=false` (breaking `init.plan-phase`, `init.phase-op`, and downstream execute/verify/ship). Numeric phase-token components must now be zero-padded (≥2 digits), so a single-digit slug word is no longer absorbed into the token. Fixed consistently across every same-class implementation — `extractPhaseToken`, `PHASE_TOKEN_FROM_DIR_RE` and `canonicalPlanStem` (health checks / plan pairing), `isDirInMilestone`'s numeric matcher (milestone filtering), and `extractCanonicalPlanId` — so the health-check and milestone-filter subsystems are fixed alongside phase resolution. (#2059)
|
||||
- **`gsd-tools config-set <key> null` now clears (removes) the key instead of persisting the literal string `"null"`.** The documented "Clear" action previously fell through the value parser and stored `"null"` — a truthy value — so "cleared" keys stayed set and `config-get` returned `"null"`; for secret keys (`brave_search`/`firecrawl`/`exa_search`) a masked success line hid a truthy value on disk that integrations could pass along as a real credential. `config-set <key> null` now deletes the key (short-circuiting the typed per-key validators so clearing an enum/boolean/number key removes it rather than being rejected), making the "Clear" flows in `settings-integrations.md` / `settings-advanced.md` actually clear. (#2058)
|
||||
- **`init plan-phase` no longer collapses foreign-prefixed task/workstream IDs into numeric phases** — a query like `MEM-01` (where `MEM` is not the configured `project_code`) used to have its prefix stripped and resolve to the unrelated numeric Phase 01; it now reports `phase_found: false` unless a phase directory or roadmap entry literally carries that prefix. The configured `project_code`'s own prefixed phases (e.g. `LKML-01` under `project_code: LKML`) continue to resolve as before. (#2056) (#2105)
|
||||
- **`phase complete` no longer ticks the wrong phase's ROADMAP checkbox** — completing a phase whose number also appears in a later phase's description (e.g. an idempotent re-run of an already-complete phase) used to mark the *wrong* phase done, because the checkbox-matching regex greedily spanned from `]` to any later "Phase N" mention instead of only the immediately-following phase title. (#2067) (#2079)
|
||||
- **`gsd-tools effort sync` no longer crashes in an installed runtime.** In any global install (e.g. `~/.claude/gsd-core/`), `effort sync` threw `Cannot find module '../../../bin/install.js'` — the command reached into the package-root `bin/install.js` for its install-time effort resolvers, but the installer only copies the `gsd-core/` subtree into a runtime home, so that file is never present there. As a result, `effort` config changes (`routing_tier_defaults` / `agent_overrides`) silently never reached installed agents without a full reinstall. The two resolvers (`readGsdEffectiveEffortConfig` + `resolveInstallTimeEffort`, with their helpers) are now extracted into a shipped `gsd-core/bin/lib/install-effort-resolver.cjs` that both `effort sync` and the installer import — a single source of truth that is always present in the installed tree. (#2076) (#2076)
|
||||
- **`model_overrides` and per-phase-type models now actually apply to the assumptions-analyzer, code-reviewer, and code-fixer agents on Claude Code.** Previously `model_overrides["gsd-code-reviewer"]` / `["gsd-assumptions-analyzer"]` / `["gsd-code-fixer"]` (and `models.verification` / `models.discuss` / `models.execution`) were accepted and resolved but silently dropped — the workflows spawned these agents with no model, so they inherited the session model and the configured routing never took effect (no warning). Every spawn now threads its resolved model: `discuss-phase-assumptions`, `code-review`, and `code-review-fix` (both the re-review and the two fixer spawns) resolve it inline, and `quick`'s review step uses the code-reviewer's own resolved model instead of the executor's. The stale "`discuss` — reserved, no subagent" model-profile docs are corrected to list `gsd-assumptions-analyzer`, and the `verification` row now includes `gsd-code-reviewer`. (#2074) (#2074)
|
||||
- **`/gsd-review`'s Antigravity CLI reviewer no longer fails silently on large prompts, unavailable pinned models, or pre-session stalls** — the `agy` invocation now uses a file-reference prompt to avoid exec arg-list overflow, is wrapped in an external wall-clock `timeout` paired with `--print-timeout` because `--print-timeout` cannot fire before `agy` creates a session, passes `--model` from `review.models.agy` when set as an escape hatch for a 404'd pinned model, and its empty-output stub now surfaces an `agy` cli.log diagnostic instead of a bare generic message. Supersedes the #687 "no external killer / inline `$(cat)`" contract, which predated `agy` gaining `--model` and predated its own guidance to pair `--print-timeout` with a terminal timeout. (#2073) (#2109)
|
||||
- **`init execute-phase`, `init verify-work`, and `init phase-op` no longer collapse foreign-prefixed task IDs to numeric phases** — `MEM-01` under `project_code: LKML` was silently stripped to `01` and resolved to the unrelated numeric Phase 01, because the #2056 guard was applied only to `init plan-phase`. The guard is now extracted into shared helpers (`guardedFindPhase` / `guardedGetRoadmapPhase`) that delegate to the canonical `isForeignPrefixedPhaseQuery` from `phase-id.cts`, and all four init commands route through them. (#2104) (#2149)
|
||||
- **`commit --files` now commits only the declared paths** — `gsd-tools commit --files A B` previously ran a bare `git commit` that absorbed the entire staged index, silently sweeping in unrelated files the caller never named. The commit now appends a pathspec (`-- <paths>`) so only the staged subset of `--files` lands in the commit; the no-`--files` default path is unchanged. Missing tracked files are still skipped (not committed as deletions, #2014), and when all declared files are missing the function short-circuits to `nothing_to_commit` instead of absorbing the index. (#2112) (#2148)
|
||||
- **Fixed unresolvable bare `require('gsd-core/...')` in `gsd-surface` command doc** — the four `require()` examples now derive the engine path from `runtimeConfigDir` (resolvable at runtime), and the reinstall hint corrects `npm i -g gsd-core` to `npm i -g @opengsd/gsd-core`. (#2116) (#2213)
|
||||
- **`milestone complete --dry-run` now prints a preview plan instead of silently mutating** — `gsd-tools milestone complete --dry-run` was neither parsed nor rejected, so a caller expecting a preview triggered the full destructive mutation (archive phases, move audit artifacts, rewrite STATE.md) with no way to back out. The `--dry-run` flag is now honored: it returns a JSON plan listing `would_archive` (roadmap, requirements, audit, phase dirs) and `would_update` (MILESTONES.md, STATE.md) targets with zero filesystem mutations. (#2118) (#2155)
|
||||
- **`/gsd-secure-phase` now has a single SECURITY.md writer** — the `gsd-security-auditor` subagent previously held `Write`/`Edit` tools and was instructed to "write SECURITY.md" with no padded `<N>-` prefix and no template frontmatter, while the orchestrator's Step 6 also wrote the phase-scoped `<N>-SECURITY.md` from `templates/SECURITY.md`. The auditor is now return-only (drops `Write`/`Edit`, returns a structured verdict with `threats_open`); the orchestrator is the sole file writer. The workflow's Step 5 spawn constraints explicitly forbid the auditor from writing SECURITY.md. (#2119) (#2154)
|
||||
- **Dead security scan exports removed; injection-scan docs corrected to match reality** — `scanEntropyAnomalies` and `shannonEntropy` were dead code with zero production callers (live hooks inline their own patterns for independence). REQ-SCAN-INJ-02/-03 now accurately describe what runs live (injection patterns, invisible Unicode) vs CI-only (base64-decode, codebase scan). (#2198) (#2211)
|
||||
- **Custom STATE.md frontmatter keys are no longer dropped on every mutating verb** — syncStateFrontmatter rebuilt the frontmatter from a fixed schema, silently dropping any custom key. It now carries forward existing keys the schema does not own. (#2202) (#2233)
|
||||
- **Non-frontend phases with `UI hint: no` are no longer blocked by the UI-SPEC gate** — the UI safety gate's token list included the bare token `UI`, which matched GSD's own `**UI hint**: no` metadata line and false-detected a UI, blocking backend/infra phases at /gsd-plan-phase. An explicit `UI hint: yes|no` is now authoritative and the hint line is no longer token-sniffed. (#2150) (#2222)
|
||||
- **OpenCode reviewer no longer silently yields an empty review on large prompts** — `/gsd-review --opencode` now invokes `opencode run --format json` and reconstructs the review from the assistant text parts, so a large-prompt run where the default `build` agent ends its turn with zero output tokens no longer produces an empty stub. When the agent genuinely emits no text, the stub now reports the stop reason, output-token count, and captured stderr instead of a generic message. (#1936) (#1992)
|
||||
- **`stale-bake-guard` hermeticity fix (test-isolation)** — the readGsdEffectiveModelOverrides subtest no longer reads the developer's real `~/.gsd/defaults.json`; the resolver now accepts a homedir seam so the test sandboxes HOME. (#2152) (#2223)
|
||||
- **`/gsd-surface` (`list`/`status`) works on Claude Code global installs** — the installer now writes a `.gsd-source` marker pointing at its `commands/gsd` source, so `findInstallSourceRoot` resolves on the global skills layout (which ships no `commands/gsd` tree) instead of throwing `could not locate commands/gsd`. (#1487) (#1487)
|
||||
- **`phase complete --phase N` now works alongside the positional form** — the phase verb family treated the first positional as the phase number, so `--phase 12` was passed as the literal phase name and failed with 'Phase --phase not found'. The phase family now accepts the --phase flag consistently with the state family, and unrecognized flags yield a usage error. (#2201) (#2231)
|
||||
- **Third-party capability skills now surface correctly after install** — a skills-only `role: feature` capability installed `active` but its skills never reached the runtime surface, `capability enable`/`set` rejected it as `unknown capability`, and `capability list` disagreed with `capability state`. `resolveSurface` now unions the composed registry's `capabilityClusters` into the surfaced skill set (no on-disk linking), the writer validates against the composed overlay-aware registry, and `capability list` carries a `surfaced` field matching `capability state`. (#2054)
|
||||
- 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. (#1910)
|
||||
- **`/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. (#1722)
|
||||
- **state record-metric no longer appends per-plan rows into the By-Phase velocity table** — it now maintains its own Per-Plan Metrics table (self-created on first use), and its auto-create scaffold header is corrected. (#2253) (#2253)
|
||||
- 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. (#1709)
|
||||
- **`last_activity` now shows your local calendar day** — the clock seam derived the date by slicing a UTC instant, so in negative-UTC-offset zones during UTC's early evening the date-only `last_activity` field jumped a day ahead of the operator's actual date (and of `last_updated`'s local date). Operator-facing date fields now use a host-local calendar day while internal/cosmetic stamps stay UTC. (#2136) (#2216)
|
||||
- **`milestone_name` is no longer clobbered with a delimiter-led fragment** — getMilestoneInfo's `##` heading regex was unanchored, so it matched a heading quoted inside backticks in the Milestones bullet and wrote garbage like `— Active Milestone` over the curated milestone name on every phase transition. Now consults the 🚧 marker first, anchors the regex to line start, strips the leading delimiter, and widens the preserve guard so a bad derive keeps the existing name. (#2135) (#2215)
|
||||
- **`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) (#1844)
|
||||
- **`/gsd-quick` no longer halts with a stale-base worktree mismatch** — the worktree executor now degrades to sequential execution when its fork base has diverged from origin/HEAD, instead of spawning a worktree guaranteed to fail the base-mismatch guard. (#1991)
|
||||
- **Setting `external_job.submit_timeout_ms` / `poll_timeout_ms` / `artifact_dir` in `.planning/config.json` now actually configures the SLURM adapter** — the keys were declared by the external-job capability but the adapter only read env vars, so config edits silently had no effect. The adapter now resolves them through the canonical capability-config seam (env override > config > registry default), surfaces the resolved `artifact_dir` in `submit` output, documents why the contribution registers at `execute:wave:post` (#1164 asks for `wave:pre`, which `execute-phase.md` does not dispatch today; wiring it is a core-loop change #1164 explicitly defers), and gains unit coverage for the CLI surface (`parseFlags`, `findPlanningDir`, `resolveExternalJobSettings`, `formatShowReport`). (#1164) (#2006)
|
||||
- **The Antigravity reviewer in `/gsd-review` no longer reviews blind** — `agy -p` never granted the agent the repo under review, so it frequently anchored on its own scratch directory and returned plan-text-only verdicts counted at full consensus weight. The reviewer is now granted the repo (capability-probed `--add-dir`) and anchored to the absolute repo root; a review that still runs without repo access is stamped `[reviewed-without-repo-access]` and down-weighted in the Consensus Summary. The cursor-agent prompt gains the same absolute-root anchor. (#2176) (#2184)
|
||||
- **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) (#1846)
|
||||
- **`requirements mark-complete` no longer reports silent success when the traceability row is missing** — it OR-ed its checkbox and table-row writes into one flag, so a checkbox-only reconcile returned a payload byte-identical to a full reconcile while the traceability row stayed Pending (and re-run masked it as already-complete). It now surfaces `table_unmatched` for IDs whose checkbox reconciled but whose table row is absent, and treats a checked box with no table row as partial rather than done. (#2140) (#2219)
|
||||
- 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. (#1832)
|
||||
- **`model_overrides` Claude model IDs now resolve to Agent-tool aliases on the claude runtime** — a full Claude model ID (e.g. `claude-sonnet-5`) in `model_overrides` was returned verbatim and silently dropped by the Claude Agent tool (whose `model` parameter documents only tier aliases), causing the spawned subagent to inherit the parent session model instead of the configured one. It now maps to the tier alias (`sonnet`/`opus`/`haiku`/`fable`), consistent with the `model_policy` path (#1144). Bare aliases, non-Claude values, and non-Claude runtimes are unchanged; a Claude ID with no alias warns once and falls through to tier resolution. (#2041) (#2048)
|
||||
- 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. (#1765)
|
||||
- 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. (#1873)
|
||||
- **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) (#1927)
|
||||
- **`/gsd-fast` now appends Quick Task rows to STATE.md again** — the log_to_state column-count guard used an off-by-one awk formula (`NF-1`) that was always one too high, so the schema gate rejected the very table quick.md creates and silently skipped the STATE.md update. Also now supports the 6-column validate-mode table. (#2133) (#2214)
|
||||
- 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) (#1968)
|
||||
- **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) (#1916)
|
||||
- **Windows install/upgrade/state-write operations no longer fail on transient antivirus/indexer file locks** — the fs.renameSync atomic-publish sites (install state, hooks config, capability ledger/lifecycle, phase/workstream/milestone dirs, roadmap, planning/state locks) now retry EPERM/EBUSY/EACCES via retryRenameSync instead of propagating the transient lock; enforced by the new local/require-fs-op-fallback lint rule (ADR-1703 Phase 6). (#1740) (#1742)
|
||||
- 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. (#1807)
|
||||
- **`phase remove` no longer destroys the Progress table when removing the last phase** — deleting a phase used a whole-document regex whose scan, on the final phase, ran past the section and swept away the `## Progress` heading and its entire tracking table; the deletion is now structurally bounded to the phase’s own section. (#2253) (#2253)
|
||||
- **`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) (#1919)
|
||||
- **Cross-AI review no longer silently drops the Codex/Claude/Gemini lanes on large plan sets** — the prompt-fed reviewer blocks in review.md invoked each CLI with no explicit timeout, so a slow source-grounded review was killed at the host default (~2 min) and the lane was silently lost. The workflow now directs a high Bash timeout and frames an empty output as a timeout (not the crash it was misdiagnosed as). (#2194) (#2226)
|
||||
- **`/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) (#1918)
|
||||
- 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)
|
||||
- **phase complete now updates STATE progress on milestone-grouped roadmaps** — deriveProgressFromRoadmap parses the ## Progress table by header (column-by-name) instead of a fixed 4-column layout, so the 5-column milestone-grouped shape is no longer silently unparsed. (#2168)
|
||||
- **Windows Claude Code hooks now work under PowerShell** — when Claude Code's hook runner resolves to PowerShell (not Git Bash), every GSD-installed hook failed with `Unexpected token` because the installer emitted bare quoted paths with no PowerShell call operator. The fix adds a `hookShell` parameter to the hook-command projection chain; when `hookShell='powershell'`, the `&` call operator is prepended. Default behavior (Git Bash, no prefix) is unchanged. (#2236) (#2261)
|
||||
- **`capability state` and `loop render-hooks` now accept `--runtime` to override the auto-detected runtime** — previously both commands parsed only `--config-dir`, so the runtime config dir was derived from the persisted `.planning/config.json` runtime (precedence `GSD_RUNTIME` → `config.runtime` → `claude`). A repo that persisted `runtime:"codex"` resolved the config dir to `~/.codex`, where the Claude skill isn't installed, so every skill-bearing capability reported `surfaced:false` and `execute:post`/`verify:post` hooks silently no-op'd when the operator drove GSD from Claude Code. `--runtime <r>` (canonicalized, so aliases like `codex-app` work) now bypasses that fallback so the config dir resolves to the explicitly-named runtime's home. Behavior without the flag is unchanged. (#2003) (#2051)
|
||||
- **`phase complete` no longer false-reports REQ-IDs as missing when the traceability table leads with a status column** — the parser required the REQ-ID in the first column, so a table shaped `| ☐ | REQ-01 | …` matched zero rows and every body REQ-ID was reported missing. It now matches REQ-IDs in any column. (#2203) (#2234)
|
||||
- **`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) (#1843)
|
||||
- **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) (#1924)
|
||||
- **`state update-progress` no longer mangles the frontmatter and discards the progress suffix** — its Progress: regex matched the raw STATE.md including frontmatter, so the YAML `progress:` key was hit first (corrupting the frontmatter) while the body line stayed stale and was silently reverted on the next write, and any descriptive suffix after the progress bar was destroyed. It now targets the body line only and preserves the suffix. (#2177) (#2224)
|
||||
- **`/gsd-ship` no longer silently drops the ship-status note from STATE on merge** — the track_shipping step committed the STATE ship-note after creating the PR but never pushed it, so on a fast merge the note stayed local-only and never reached the default branch. The ship-note is now pushed onto the PR branch with a `[ci skip]` trailer so it lands on merge without a redundant pipeline. (#2138) (#2217)
|
||||
- **`/gsd-debug` no longer stalls on a phantom background handoff** — the orchestrator treated the foreground session-manager spawn as a background task and queried its agent ID via TaskOutput (which needs a task ID), then waited on a handoff that was never queryable. The workflow now states the spawn is foreground/blocking, forbids passing an agent ID to TaskOutput, and gives a lost-handoff recovery path. (#2196) (#2227)
|
||||
- **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) (#1845)
|
||||
- **`phase complete` no longer checks the wrong ROADMAP checkbox or writes the plan count into a shipped milestone** — the roadmap mutators ran unanchored and un-milestone-scoped, so they could flip a bullet inside a backticked prose literal or a Backlog entry instead of the closing phase's, and write the plan count into a same-numbered phase in a shipped milestone. The checkbox flip is now line-anchored and both writers are scoped to the current milestone. (#2200) (#2229)
|
||||
- **`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. A project-code-prefixed heading present only as a summary/checklist line (no matching detail section) now reports a `malformed_roadmap` diagnostic — for both prefixed and bare-number queries — instead of a silent empty result. (#2114) (#2139)
|
||||
- **`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) (#1917)
|
||||
- **`phase complete` now reads milestone-grouped ROADMAP progress tables** — progress reported 0% on projects whose Progress table carries a Milestone column, because the reader assumed a fixed column position; it now resolves progress columns by name so both flat and milestone-grouped tables work (#2137). Quick Tasks logging via `/gsd:fast` also appends schema-correct, lock-safe rows instead of guessing the column count in shell (#2133). (#2248) (#2248)
|
||||
- **Skill-bearing capabilities now surface correctly on flat command-layout installs** — on an install using the flat `commands/gsd-<stem>.md` source layout (e.g. a Claude Code local project install with no `commands/gsd/` subdir), every skill-bearing capability (`nyquist`, `code-review`, `security`, `ui`, `mempalace`, `ai-integration`, `profile-pipeline`) was silently reported `surfaced:false`/`enabled:false`/`active:false`, so their loop hooks (`verify:post`, `execute:post`, etc.) never fired even with the corresponding `workflow.*` toggle on. The skill-manifest resolver now detects the flat layout and produces the same stems the nested `commands/gsd/*.md` loader does. (#1858) (#2049)
|
||||
- **Roadmap, requirements, and state table edits are confined to the right table** — the last ad-hoc table writers (phase completion updating roadmap progress, `requirements mark-complete`, and `state record-metric`/velocity) now route through the shared markdown-table seam, so a stray decoy table elsewhere in a document can no longer swallow a phase-progress update, a single ragged neighbouring row no longer silently aborts the whole edit, and per-plan metric recording no longer drops trailing section content or duplicates the section. (#2253) (#2253)
|
||||
- **ROADMAP phase edits can no longer escape their section** — completing a phase updated its plan count and per-plan checkboxes with whole-document regexes that could bleed into a neighbouring phase; those per-phase writes are now structurally bounded to the phase own section via a new `withSection` / `withPhaseSection` seam (#2130, #2067, #2080). (#2250) (#2250)
|
||||
- **STATE.md `## Session` fields now resolve on Windows** — the session-section reader used a `\n`-only heading regex that silently failed on a CRLF `## Session` heading, nulling all session state on Windows checkouts; it now reads through the CRLF-safe section seam. (#2253) (#2253)
|
||||
- **Bullet/em-dash ROADMAP phases no longer resolve to `Phase null`** — the roadmap phase lookup matched only ATX headings with a colon, so a bullet entry like `- [ ] **Phase N — Name**` (which the roadmapper emits) failed to resolve and `Phase null` landed in STATE.md; a bullet-only ROADMAP also broke the milestone phase count. Phase lookup and the milestone filter now accept bullet/checkbox entries with an em-dash/en-dash/hyphen/colon separator. (#2199) (#2228)
|
||||
- **Linuxbrew users no longer lose all GSD-managed hooks after `brew upgrade node`** — normalizeNodePath only recognized macOS Homebrew Cellar paths, so on Linux the version-pinned node path stayed baked into hook commands and 404'd after a node bump (and reinstall couldn't repair it). It now rewrites any Homebrew Cellar path — Intel, Apple Silicon, Linuxbrew, custom HOMEBREW_PREFIX — to the stable `<prefix>/bin/node` symlink. (#2185) (#2225)
|
||||
- **`milestone complete` no longer corrupts the recorded phase** — closing a milestone (e.g. `v0.5`) previously overwrote `current_phase` in STATE.md with the version's minor digit, and a follow-up `state complete-phase` mined a bogus `0.5` token and rewrote the file; phase resolution is now anchored so the real phase is preserved and a milestone-closure line is rejected. (#2111) (#2131)
|
||||
- **Headless MemPalace capture no longer fails silently** — the headless invocation `mempalace mine <path> --wing <wing> --room <room>` used a `--room` flag that does not exist on the `mine` subcommand (only `search` accepts `--room`), causing every headless/no-MCP capture run to fail with `unrecognized arguments: --room` and silently skip (onError: skip). The fix replaces the flag with MemPalace's documented room-assignment mechanism: stage the artifact under a room-named subfolder with a `mempalace.yaml` taxonomy so `detect_room()` assigns it via folder-path match. (#2220) (#2260)
|
||||
- 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. (#1909)
|
||||
- **`commit_docs` no longer silently disables on CRLF `.gitignore` repos** — git check-ignore falsely reports a trailing-slash path (e.g. `.planning/`) as ignored when the .gitignore has CRLF line endings with blank lines. isGitIgnored now strips trailing slashes before querying, so the false positive cannot occur. (#2206) (#2235)
|
||||
- **Phase-directory resolution fails loud on cross-project collisions** — when two unrelated GSD projects share a `.planning/phases/` tree, a bare phase number silently resolved to the first `0N-*` directory found. The fix detects multiple matches and surfaces an `ambiguous_matches` result. (#2237) (#2262)
|
||||
- **`scanPhasePlans` no longer counts PLAN-REVIEW artifacts as executable plans** — `*-PLAN-REVIEW.md` files were counted by the loose `/PLAN/i` fallback. The fix adds a `PLAN_REVIEW_RE` exclusion before the fallback. (#2252) (#2263)
|
||||
- **Milestone audit no longer flags a not-yet-validated phase as a Nyquist failure** — a phase that was planned but never run through `validate-phase` now reports as NOT-VALIDATED (a "run validate-phase" TODO) instead of collapsing into PARTIAL alongside phases whose validation genuinely failed. (#2117) (#2209)
|
||||
|
||||
### Security
|
||||
|
||||
- **`gate="blocking-human"` checkpoints are no longer auto-approved by the execute-phase orchestrator** — the package-legitimacy gate (#2827) spans two layers: `gsd-executor` refuses to auto-approve a `gate="blocking-human"` checkpoint and escalates it via `checkpoint_return_format` so a human can vet the package, and `execute-phase`'s `checkpoint_handling` step decides what happens next. That step dispatched purely on checkpoint *type* and never read `gate`, so under `--auto` / `--chain` it immediately auto-approved the very checkpoint the executor had just refused to auto-approve (`human-verify → {user_response} = "approved"`). The slopsquatting defence was therefore inert in exactly the unattended mode where nobody is watching: an `[ASSUMED]`/`[SUS]` package reached install with no human ever seeing the verification prompt. `checkpoint_handling` now carves out `gate="blocking-human"` (and the package-legitimacy `what-built` markers) ahead of every auto-mode branch, routing those checkpoints to the standard present-to-user flow regardless of type. `references/checkpoints.md` documents the `gate` attribute and its two values for the first time — previously `blocking-human` appeared nowhere outside `agents/gsd-executor.md`, so no planner had a documented way to author a checkpoint that auto-mode could not bypass. The existing regression test asserted the executor half only; it now asserts the orchestrator half too, which is why it stayed green while the gate was open. (#2107) (#2113)
|
||||
- **Hardened phase/roadmap/plan markdown parsing against quadratic-time (ReDoS) CPU exhaustion** — a crafted `ROADMAP.md`, `STATE.md`, or `PLAN.md` with large runs of unclosed `(`, `[`, `<tag>`, `<!--`, or `<details>` could drive the phase-header, Plans-count, `files_modified`, and `<tag>`-block parsers into O(n²) scans (tens of seconds on a ~1.5 MB file). Every affected regex is now linear: header tag/bracket clauses are length-bounded, the Plans-count scan is section-local, and all `<tag>…</tag>` extraction routes through a single ReDoS-safe seam. (#2128) (#2141)
|
||||
- **Installer writes are now confined to the declared config home** — the workflow/skill emit path (`copyWithPathReplacement`) and the Codex config writer (`installCodexConfig`) now reject any destination that escapes the install root: crafted or absolute paths, path-separator agent names, and pre-existing symlinks are refused before any delete or write. Fail-closed: an install write with no declared root is rejected rather than written unconfined. (#1725)
|
||||
- **Install write-confinement (ADR-1239 Phase B)** — the installer now rejects any runtime-descriptor `destSubpath` that would write or delete outside the user's config home (path traversal, the config root itself, NUL bytes) and refuses to follow a pre-existing symlink that escapes it. Hardening only; no change to legitimate installs. (#1706)
|
||||
|
||||
## [1.6.1] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
149
CONTEXT.md
149
CONTEXT.md
@@ -52,6 +52,12 @@ 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
|
||||
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`).
|
||||
|
||||
### STATE.md Status Lifecycle (ADR-2207)
|
||||
The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → `<version> milestone complete` (terminal, written only by the milestone-close verb `milestoneCompleteCore`) → `Awaiting next milestone` (archived). Phase-completion verbs write `All phases complete` on the last phase — never `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). `normalizeStateStatus` maps any status containing "complete" → `completed`, so consumers using the normalized projection (workstream inventory's `status` field, statusline) recognize `All phases complete` without code changes. Note: `isCompletedInventory` (workstream-inventory-builder.cts) intentionally checks only for the terminal `\bmilestone\s+complete\b` / `\barchived\b` — `All phases complete` returns `false` (intermediate, not terminal).
|
||||
|
||||
### Query Execution Policy Module
|
||||
Module owning query transport routing policy projection (`preferNative`, fallback policy, workstream subprocess forcing) at execution seam.
|
||||
|
||||
@@ -65,7 +71,7 @@ Canonical command normalization and resolution Interface (`query-command-resolut
|
||||
Module owning command resolution, policy projection (`mutation`, `output_mode`), unknown-command diagnosis, and handler Adapter binding at one seam for query dispatch.
|
||||
|
||||
### Init Command Module
|
||||
Module owning the `init.*` family of query handlers that compose atomic queries into the flat JSON bundles consumed by init workflows (`/gsd-execute-phase`, `/gsd-plan-phase`, `/gsd-verify-work`, `/gsd-new-project`, `/gsd-manager`, `/gsd-progress`, `/gsd-resume`, etc.). Source of truth: `gsd-core/bin/lib/init.cjs` — the basic handlers (plus `withProjectRoot` project-identity injection) and the 3 heavyweight handlers (`initNewProject`, `initProgress`, `initManager`). All handlers return `{ data: <flat JSON> }`. Test seams: `tests/init.test.cjs` and `tests/init-manager.test.cjs` (cover withProjectRoot precedence, progress/manager precedence regression #2674, workstream scoping regression #3196, and cross-milestone dependency regression #2267). (The SDK `handlers/init/*.ts` sources and the `init*.test.ts` seams were retired with the SDK package per ADR-0174.)
|
||||
Module owning the `init.*` family of query handlers that compose atomic queries into the flat JSON bundles consumed by init workflows (`/gsd-execute-phase`, `/gsd-plan-phase`, `/gsd-verify-work`, `/gsd-new-project`, `/gsd-onboard`, `/gsd-manager`, `/gsd-progress`, `/gsd-resume`, etc.). Source of truth: `src/init.cts` and the compiled `gsd-core/bin/lib/init.cjs`; onboarding routing readiness lives in `src/onboard-projection.cts`. The basic handlers (plus `withProjectRoot` project-identity injection) and the heavyweight handlers (`initNewProject`, `initOnboard`, `initProgress`, `initManager`) return `{ data: <flat JSON> }`. Test seams: `tests/init.test.cjs`, `tests/onboard-command.test.cjs`, and `tests/init-manager.test.cjs` (cover withProjectRoot precedence, onboarding projection/rendering, progress/manager precedence regression #2674, workstream scoping regression #3196, and cross-milestone dependency regression #2267). (The SDK `handlers/init/*.ts` sources and the `init*.test.ts` seams were retired with the SDK package per ADR-0174.)
|
||||
|
||||
### Command Routing Hub
|
||||
Single dispatch seam (`gsd-core/bin/lib/command-routing-hub.cjs`) that centralizes CJS routing, the no-throw pure-result contract, typed error variants, and dispatch-event emission for all command family adapters. Interface: `createHub({ cjsRegistry, manifest, logger }) → hub`; `hub.dispatch({ family, subcommand, args, cwd, raw, parentTraceId? }) → Result` where `Result = { ok: true, data } | { ok: false, kind, ...typedPayload }` and `kind ∈ { UnknownCommand, InvalidArgs, HandlerRefusal, HandlerFailure }`. The `InvalidArgs` variant carries an optional `exitReason?: string` field (amendment #1642 / #1644 Phase 1) holding the `ERROR_REASON` enum value, separate from `reason` (the explanation text); the `makeInvalidArgs(arg, reason, exitReason?)` factory omits the field when the third arg is absent, undefined, or empty — preserving the strict-keys invariant tested at `tests/command-routing-hub.test.cjs:444`. The Hub is single-runtime (no mode selection, no sdkLoader), never prints, never exits, never throws. Adapters call `createHub`, dispatch, then translate the pure Result to `output()`/`error()` calls; when an `InvalidArgs` Result carries `exitReason`, the adapter passes it as the second arg to `error(message, exitReason)` so the JSON-error envelope (`GSD_JSON_ERRORS=1`) preserves the typed reason. Source: `gsd-core/bin/lib/command-routing-hub.cjs`; ADR: `docs/adr/0174-retire-gsd-sdk-package-boundary.md` (§5 amended #1642).
|
||||
@@ -118,20 +124,35 @@ Module owning bounded, never-throw git repository introspection — the single s
|
||||
### Runtime Name Policy Module
|
||||
Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`.
|
||||
|
||||
### Host-Integration Interface
|
||||
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with eight closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016.
|
||||
|
||||
### Statusline
|
||||
Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens` and `statusline.state_format`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope.
|
||||
|
||||
### Install Engine Module
|
||||
Module owning the layout-driven runtime-artifact install pipeline — `installRuntimeArtifacts`, `uninstallRuntimeArtifacts`, `installOpencodeFamilySkills`, and their cluster helpers (`_copyStaged`, `_snapshotDir`/`_restoreDir`, legacy-migration + GSD-entry pruning, user-artifact preserve/restore). Extracted from the 12k-line `bin/install.js` (ADR-1239 Phase B, #1679) so adapters import the engine instead of reaching into the installer. Commit-attribution resolution stays in `bin/install.js` and is injected via a `resolveAttribution` parameter (the engine takes no config I/O). Source: `src/install-engine.cts` -> `gsd-core/bin/lib/install-engine.cjs`.
|
||||
|
||||
### Installer Migration Authoring Guard Module
|
||||
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
|
||||
|
||||
### Installer Module
|
||||
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Seven runtimes with non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). The remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout unchanged. See Skill Surface Budget Module and Runtime Artifact Layout Module.
|
||||
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, hermes, kilo, kimi, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Seven runtimes with non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). The remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout unchanged. See Skill Surface Budget Module and Runtime Artifact Layout Module.
|
||||
|
||||
### I/O Module
|
||||
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
|
||||
|
||||
### Markdown Sectionizer
|
||||
Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `<tagName>…</tagName>` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).
|
||||
Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `<tagName>…</tagName>` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern); `withSection(content, target, edit) → string` (resolve the section whose heading matches `target` — exact heading text or a `HeadingToken` predicate — and run `edit(body)` against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or `edit` returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).
|
||||
|
||||
### Markdown Table Model
|
||||
Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-table.cjs`, generated from `src/markdown-table.cts`; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: `parseMarkdownTable(sectionText) → Result<MarkdownTable>` (parses the first GFM pipe table found; typed `{ok:false,reason}` parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); `MarkdownTable` (`{columns: string[], rows: Record<string,string>[]}`, rows addressed by column name, not position); `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — re-exported from the Write-Set Module, the ADR-2143 §5 single source of truth for this shape, so existing importers of `Result` from `markdown-table.cjs` are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`; the two never mix); `TABLE_SCHEMAS` (`Record<string, CanonicalTableVariant[]>` — the canonical column-header variants for every GFM table GSD parses or generates: `RoadmapProgress` flat/milestone-grouped, `RequirementsTraceability`, `QuickTasks` no-status/with-status, `Security` trust-boundaries/threat-register/accepted-risks/audit-trail); `matchTableSchema(columns) → {id,label}\|null` (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (`tests/markdown-table.test.cjs`) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` is the first consumer: it locates the Progress section via the Markdown Sectionizer's `collectSection` and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed `Status` was always the 3rd cell, which broke for the 5-column milestone-grouped `Milestone` variant).
|
||||
|
||||
### Write-Set Module
|
||||
Shared fail-loud `Result<T>` and per-surface write-set contracts (`gsd-core/bin/lib/write-set.cjs`, generated from `src/write-set.cts`; ADR-2143 §5/§6, epic #2143). Pure, Node built-ins only, no I/O. Exports: `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — ADR-2143 §5 fail-loud parse shape, never a bare `null` a caller can mistake for "empty but fine"; the single source of truth `markdown-table.cjs` re-exports so its existing importers are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`); `WriteOutcome` (`{surface: string, applied: boolean}` — one surface's outcome within a multi-surface write); `WriteSet` (`WriteOutcome[]`); `writeSetComplete(ws) → boolean` (true only when the set is non-empty AND every surface applied — ADR-2143 §6's "no OR-into-one-flag" rule: a command that mutates more than one surface must not collapse independent surface outcomes into a single boolean, the anti-pattern that let a checkbox-only partial write (#2140) report full success). `milestone.cts`'s `requirements mark-complete` handler is the first consumer: it reports a `write_set` (`checkbox`/`traceability` surfaces) and `write_set_complete` alongside its existing `updated`/`marked_complete`/`already_complete`/`not_found`/`table_unmatched` fields, which remain computed exactly as before — the write-set is additive, structured ADR-2143 documentation of the same per-surface facts #2140's tactical fix already exposed via `table_unmatched`.
|
||||
|
||||
### Roadmap Parser Module
|
||||
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).
|
||||
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`, `withPhaseSection`). `withPhaseSection(content, phaseId, edit)` resolves a phase's `### Phase N` detail-section heading via the #2121 phase-id source (`phaseMarkdownRegexSource`) and delegates to the markdown-sectionizer seam's `withSection`, so a per-phase ROADMAP edit is bounded to that phase's own section (ADR-2143 §4). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`, `markdown-sectionizer`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).
|
||||
|
||||
### Core Utilities Module
|
||||
Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (`toPosixPath`), filesystem scanning (`detectSubRepos`, `readSubdirectories`, `getPhaseFileStats`, `pathExistsInternal`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `filterPlanFiles`, `filterSummaryFiles`, `extractCanonicalPlanId`, `timeAgo`). Depends only on Node built-ins and already-leafed modules (`phase-id` for `comparePhaseNum`, `planning-workspace` for `findContextMdIn`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/core-utils.cjs` (generated from `src/core-utils.cts`).
|
||||
@@ -155,13 +176,13 @@ Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home,
|
||||
Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `gsd-core/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `gsd-core/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `gsd-core/bin/lib/clusters.cjs`; per-runtime state persists in `<runtimeConfigDir>/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011.
|
||||
|
||||
### Runtime Artifact Layout Module
|
||||
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660.
|
||||
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `<configDir>/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. See ADR-3660.
|
||||
|
||||
### Runtime Artifact Conversion Module
|
||||
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383).
|
||||
|
||||
### Runtime Artifact Install Plan Module
|
||||
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
|
||||
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
|
||||
|
||||
### Command Roster Module
|
||||
Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state.
|
||||
@@ -175,6 +196,9 @@ A bundle delivering one optional GSD feature, toggled as a unit at install or af
|
||||
### Loop Host Contract
|
||||
Generated description of what the five-step loop (Discuss → Plan → Execute → Verify → Ship) exposes as extension points: per-step loop points, agent roles, and core artifacts. Sourced from structured `<!-- gsd:loop-host ... -->` HTML-comment markers embedded near the top of each of the five step workflow files (`discuss-phase.md`, `plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`). Generated by `scripts/gen-loop-host-contract.cjs` → `gsd-core/bin/lib/loop-host-contract.cjs` (ADR-894 §3 phase 3a-impl-2). Covers exactly the 12 canonical points (discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post). The generator enforces a drift guard: every declared non-orchestrator agent role must correspond to an actual agent reference in the workflow file. Consumed by `gen-capability-registry.cjs` (replaces the former inline `LOOP_HOST_CONTRACT` constant). Run `node scripts/gen-loop-host-contract.cjs --write` after editing a workflow step marker.
|
||||
|
||||
### Gate Predicate Evaluator Module
|
||||
Pure, deps-injected evaluator for capability gate `check.predicate` blocks (#2008, ADR-2008). Prior to #2008 the registry validator accepted `check.predicate` (one of exactly-one-of `query`/`predicate`/`agentVerdict`) and the loop-resolver rendered it, but nothing EVALUATED a declared predicate — only `check.query` was enforced (dispatched via `gsd_run check <query>`), and built-in gates like `security` worked only via hard-coded `capId` prose branches in `ship.md`/`execute-phase.md`/`verify-work.md`. This module is the generic evaluation path: `evaluatePredicate(predicate, context, deps) → { block, message, details? }` dispatches by `predicate.kind` through a `KIND_TABLE`. Built-in kind (v1): `command-exit-zero` — runs a declared command in a bounded `sh -c` subprocess (production binding: `shell-command-projection.execTool`) at the project root, inheriting env; exit 0 ⇒ pass, non-zero ⇒ block, timeout (SIGTERM) ⇒ block; interpolates `${PHASE_NUMBER}`/`${PHASE_DIR}`/`${PHASE_REQ_IDS}` from gate context. A THROWN error (malformed predicate, non-positive/non-finite timeout, command >4096 chars, unknown kind) maps at the CLI seam to a non-zero check-command exit, which the workflow's two-step gate contract treats as a step-1 command failure routed per `onError` — so an evaluator bug is never conflated with a legitimate block decision. Leaf pure module (no fs/child_process/config — subprocess seam injected). CLI entry: `gsd_run check predicate --predicate '<json>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw`, wired into `check-command-router.cts:cmdCheckPredicate`; the three generic workflow gate-dispatch sites (`execute:wave:post`, `execute:post`, `plan:post`) branch on `check` shape (`query` vs `predicate`). Source of truth: `src/gate-predicate-evaluator.cts`. Docs: `docs/reference/gate-predicates.md`, `docs/how-to/command-exit-zero-gate.md`.
|
||||
|
||||
### Capability Registry
|
||||
Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. Each feature capability's entry in `capabilities` now includes the optional `activationKey` field (the dotted config key that gates the whole capability, e.g. `"graphify.enabled"`; absent means no config gate). ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. ADR-857 phase 4a adds two derived views: `capabilityClusters` (`{ <capId>: [<skill stems>] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ <capId>: { tier, profiles: [...] } }` — the tier-derived index: suffix of `PROFILE_RANK` starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty `skills` array). The generator enforces a HARD gate (throws) if a capId matching a `CLUSTERS` key has a mismatched skill set, and emits SOFT `⚠ pending-reconciliation` warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. `install` and `surface` are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities/<id>/capability.json`.
|
||||
|
||||
@@ -184,6 +208,12 @@ ADR-857 phase 3b seam that merges capability-declared config slices into the `lo
|
||||
### Capability Registry Overlay
|
||||
Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that composes the frozen first-party Capability Registry (`capability-registry.cjs`) with a validated installed overlay of third-party capability manifests discovered at load time. Install roots are global (`$GSD_HOME/.gsd/capabilities/<id>/capability.json`, where `GSD_HOME` defaults to `~`) and project (`<projectRoot>/.gsd/capabilities/<id>/capability.json`). Primary interface: `loadRegistry({ includeInstalled }) → registry` — when `includeInstalled` is true the overlay is merged via the canonical `buildRegistry` so all derived views (bySkill, byAgent, byLoopPoint, configKeys) cover first-party and overlay entries identically. First-party always wins: any overlay entry whose id, owned skill/agent stem, or federated config key collides with first-party, or whose id uses a reserved `gsd-`/`gsd-core-`/`anthropic-` prefix, is rejected at load time. Load-time re-gate: an overlay failing schema validation or whose `engines.gsd` semver range does not satisfy the running GSD version is skipped with a warning and never crashes the load loop. Per-hook-kind policy: a skipped capability that declared a `gate`-kind hook fails CLOSED (the loop resolver injects a blocking gate); skipped `step` or `contribution` capabilities skip open. A capability dir whose co-located ledger entry carries an in-flight `_pending` intent (a crashed/uncommitted install or upgrade, ADR-1244 Phase 4) is skipped OPEN (never activated until reconciliation commits or rolls it back). #1459 user-owned consent gate: a PROJECT-scope overlay is activated (declarative surfaces AND command dispatch) ONLY when the user-owned Capability Consent Store holds a record for `(realpath(projectRoot), id)` whose stored `contentHash` equals the bundle content hash the loader RECOMPUTES at load (`bundleContentHash(capDir)` over the whole on-disk bundle) — NOT the repo-plantable ledger integrity nor the executable-only disclosure signature — otherwise the cap is DISCOVERED-BUT-INACTIVE (a warning carrying `kind:'unconsented'`, no surfaces, empty commandRoots), so a forged/cloned in-repo project ledger or any post-consent tamper no longer activates anything; GLOBAL scope (under the user's own home) is trusted without a record, and the global-vs-project root dedup/escalation is realpath-keyed so a symlinked `GSD_HOME` aliasing the project root cannot bypass the gate (finding 1). The consent lookup is wrapped to fail CLOSED (inactive); both the per-scope ledger AND the `capability.json` manifest are read via the shared bounded `readSmallRegularFile` (a repo-planted FIFO/oversized ledger or manifest can no longer hang or OOM the loader — finding 2). The loader reuses the ledger's shared `isValidLedgerEntry` for committed-entry parity. Consumers wired to the overlay-aware registry: `config-loader.cjs`, `config-schema.cjs`, `capability-state.cjs`, `loop-resolver.cjs`.
|
||||
|
||||
### Community Capability Registry
|
||||
Human-facing discoverability catalog (`docs/registries/capability-registry.md`, generated from `docs/registries/capabilities.json`; issue #2182) listing third-party Feature Capabilities registered by a docs PR so a solo developer can find one before installing it. Distinct from **Capability Registry** (the generated runtime manifest compiled from first-party `capability.json` declarations, ADR-894) and **Capability Registry Overlay** (the runtime seam that merges an installed third-party manifest into that generated registry at load time, ADR-1244 D2): this registry is a static document rendered by `scripts/gen-registry.cjs`, not a runtime data structure or loader. Each entry enumerates the capability's Loop Extension Points and hook kinds so a reader can judge blast radius before running `gsd capability install`, and declares its `engines.gsd` range. Inclusion is an explicit non-endorsement — a maintainer merged a link, nothing more — per `docs/registries/README.md`.
|
||||
|
||||
### EoS Registry
|
||||
Human-facing discoverability catalog (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the **Community Capability Registry**, but enumerate the six interface points, the eight negotiated axes, and `protocolVersion` in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See `docs/registries/README.md` for the full entry schema.
|
||||
|
||||
### Capability Validator
|
||||
Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`.
|
||||
|
||||
@@ -194,7 +224,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)} | ||||