* fix(#2305): stage shared guard hooks for Kilo — drop skipSharedHooksInstall Kilo's capability descriptor declared BOTH hostBehaviors.nativePlugin (a plugin that spawns the shared PreToolUse guard scripts as subprocesses) AND hostBehaviors.skipSharedHooksInstall:true, which suppresses staging of hooks/*.js into the Kilo config dir. The plugin's runHook treats an absent hook script as a silent allow, so every guard it spawned (gsd-prompt-guard, gsd-read-guard, gsd-worktree-path-guard) no-opped on every Kilo install. OpenCode uses the byte-identical plugin with hook staging on and is unaffected — it is the reference shape. The skip flag predates Kilo's plugin surface: it dates to #1821 (hooks were dead weight for a runtime with no hook consumer), and #2093 added the hooks-dependent nativePlugin without revisiting it. - capabilities/kilo/capability.json: remove skipSharedHooksInstall (regenerated gsd-core/bin/lib/capability-registry.cjs accordingly) - bin/install.js: correct the stale #1821 comments claiming Kilo has no plugin surface - tests/kilo-upgrades.test.cjs: install-fixture tests (global + local) asserting the guard scripts land where the plugin's walk-up resolves them; an end-to-end test driving a disallowed out-of-worktree write through the REAL installed Kilo tree and asserting the guard rejects it; a cross-runtime descriptor invariant (nativePlugin and skipSharedHooksInstall:true must never coexist) - tests/kilo-imperative-reference.test.cjs: flip the pinned assertion - golden fixtures regenerated (kilo now stages the 24 hook files, same set as OpenCode) Fixes #2305 * fix(#2305): warn loudly when a guard hook script is missing (runHook) runHook's absent-file branch returned a silent exit-0 allow — the mechanism that let #2305 ship undetected: with the hooks bundle never staged on Kilo, every PreToolUse guard the plugin spawned resolved to "file not found → allow" with zero signal anywhere. Keep the adapter's design contract (a missing hook must never break the tool call — pinned by the existing adapter test) but make the absence loud: console.error once per hook file, naming the unresolved path and the remediation. Applied identically to .kilo/ and .opencode/ plugin copies (byte-parity guard). Golden parity fixtures regenerated (the installed plugin file's hash changed). Fixes #2305 * chore(#2305): add changeset fragment * test(#2305): include gsd-workflow-guard.js in the staged-guards regression list The native plugin spawns four guards on write-like tool calls — the regression test's PLUGIN_GUARD_HOOKS list covered three. Staging itself was already asserted via the golden fixtures (the full bundle), but the named per-guard assertion should cover every guard the plugin actually dispatches. Surfaced by cross-AI review of PR #2327. * test(#2305): update the #1821 tests that encoded Kilo's false no-plugin premise The #1821 hook-copy test asserted Kilo must receive no staged hooks — the exact behavior this PR reverses (and the cause of all 8 CI failures). Kilo moves from the ZCode "no dead hooks" loop to the OpenCode group, with positive assertions on the new contract: the three guard hooks the plugin spawns, hooks/lib/git-cmd.js, and plugins/gsd-core.js all staged. The integration runtime contract flips kilo packageJson to true (the CommonJS marker ships with the bundle), and the pi contract comment no longer cites Kilo as a no-plugin runtime. * chore(#2305): scope the queued #1821 changeset fragment to ZCode only The fragment still claimed the installer skips hooks for Kilo — rendering both it and this PR's fragment into the same release would ship two contradictory statements about Kilo's install behavior. It now claims ZCode only and notes that #2327 reverses the Kilo half. * chore(#2305): rename changeset fragment to the generator naming convention 2305-kilo-stage-guard-hooks.md -> loud-guard-hooks.md, matching the <adjective>-<noun>-<noun> shape npm run changeset generates (review nit). --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
746 lines
30 KiB
JavaScript
746 lines
30 KiB
JavaScript
/**
|
|
* 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 }}
|
|
*/
|
|
const warnedMissingHooks = new Set();
|
|
|
|
function runHook(hookFile, payload, opts = {}) {
|
|
const hookPath = path.join(HOOKS_DIR, hookFile);
|
|
if (!fs.existsSync(hookPath)) {
|
|
// A missing guard script means the guard is silently NOT enforced — the
|
|
// exact failure mode of #2305 (plugin staged, hooks bundle not). Never
|
|
// break the tool call (the adapter's design contract), but never be
|
|
// silent about it either: warn loudly, once per hook file.
|
|
if (!warnedMissingHooks.has(hookFile)) {
|
|
warnedMissingHooks.add(hookFile);
|
|
console.error(
|
|
`[gsd-core] hook script missing: ${hookPath} — ${hookFile} is NOT ` +
|
|
"enforced. The GSD install may be incomplete; reinstall (or run " +
|
|
"/gsd-update) to restage the hooks/ bundle.",
|
|
);
|
|
}
|
|
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;
|