@opengsd/gsd-core 1.7.0-rc.1 → 1.7.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +711 -0
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-code-fixer.md +2 -0
- package/agents/gsd-code-reviewer.md +2 -0
- package/agents/gsd-codebase-mapper.md +2 -0
- package/agents/gsd-debugger.md +2 -0
- package/agents/gsd-doc-writer.md +2 -0
- package/agents/gsd-eval-auditor.md +2 -0
- package/agents/gsd-executor.md +9 -6
- package/agents/gsd-integration-checker.md +2 -0
- package/agents/gsd-nyquist-auditor.md +2 -0
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-plan-checker.md +2 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-roadmapper.md +2 -0
- package/agents/gsd-security-auditor.md +2 -0
- package/agents/gsd-ui-auditor.md +2 -0
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +2 -0
- package/agents/gsd-verifier.md +4 -2
- package/bin/install.js +118 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +18 -7
- package/gsd-core/bin/lib/capability-loader.cjs +27 -9
- package/gsd-core/bin/lib/capability-registry.cjs +51 -49
- package/gsd-core/bin/lib/capability-source.cjs +22 -7
- package/gsd-core/bin/lib/capability-validator.cjs +24 -2
- package/gsd-core/bin/lib/commands.cjs +2 -1
- package/gsd-core/bin/lib/frontmatter.cjs +53 -6
- package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
- package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
- package/gsd-core/bin/lib/host-integration.cjs +61 -0
- package/gsd-core/bin/lib/init.cjs +34 -6
- package/gsd-core/bin/lib/milestone.cjs +49 -10
- package/gsd-core/bin/lib/phase-id.cjs +18 -0
- package/gsd-core/bin/lib/phase.cjs +37 -27
- package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
- package/gsd-core/bin/lib/probe-core.cjs +44 -4
- package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
- package/gsd-core/bin/lib/roadmap.cjs +28 -20
- package/gsd-core/bin/lib/state-transition.cjs +15 -0
- package/gsd-core/bin/lib/state.cjs +27 -8
- package/gsd-core/bin/lib/validate.cjs +2 -1
- package/gsd-core/bin/lib/verify.cjs +6 -4
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -8
- package/gsd-core/references/agent-skills-bootstrap.md +60 -0
- package/gsd-core/references/model-profiles.md +27 -0
- package/gsd-core/workflows/autonomous.md +22 -24
- package/gsd-core/workflows/complete-milestone.md +6 -10
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/forensics.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +3 -3
- package/gsd-core/workflows/new-milestone.md +6 -0
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
- package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
- package/gsd-core/workflows/plan-phase.md +3 -158
- package/gsd-core/workflows/review.md +7 -2
- package/gsd-core/workflows/settings-advanced.md +10 -10
- package/gsd-core/workflows/verify-work.md +1 -2
- package/package.json +3 -1
- package/scripts/run-tests.cjs +51 -1
- package/scripts/sync-manifest-versions.cjs +66 -14
- package/skills/gsd-review/SKILL.md +6 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD plugin for OpenCode.ai (CommonJS)
|
|
3
|
+
*
|
|
4
|
+
* Architecture: SUBPROCESS REUSE. Instead of re-implementing hook logic inside
|
|
5
|
+
* the plugin, this file is a thin adapter that spawns the existing Claude Code
|
|
6
|
+
* hook scripts under hooks/ as child processes. The hooks speak a stable
|
|
7
|
+
* protocol (JSON on stdin, JSON + exit code on stdout); this adapter:
|
|
8
|
+
* 1. Translates OpenCode plugin events into Claude Code hook payloads
|
|
9
|
+
* 2. Spawns `node <HOOKS_DIR>/<hook>.js` with the payload on stdin
|
|
10
|
+
* 3. Translates hook output back into OpenCode semantics
|
|
11
|
+
* - block → throw Error (OpenCode returns the error to the model)
|
|
12
|
+
* - advisory → output.metadata + console.error (best-effort surfacing)
|
|
13
|
+
*
|
|
14
|
+
* Namespace conversion (/gsd:xxx → /gsd-xxx) reuses scripts/fix-slash-commands.cjs
|
|
15
|
+
* via require(), keeping the single source of truth.
|
|
16
|
+
*
|
|
17
|
+
* ── Two distribution shapes, one adapter (issue #1914) ─────────────────────
|
|
18
|
+
* This single file serves both distribution paths, distinguished at load time
|
|
19
|
+
* by REPO_ROOT (path.resolve(__dirname, "../..")):
|
|
20
|
+
*
|
|
21
|
+
* • Option 1 — file copy (the supported GSD path). `bin/install.js` copies
|
|
22
|
+
* this file to <opencodeConfigDir>/plugins/gsd-core.js, so REPO_ROOT is the
|
|
23
|
+
* OpenCode config dir. GSD's own install already stages `hooks/*.js` and
|
|
24
|
+
* `gsd-core/` there (ADR-857 skips hook *registration* for OpenCode, not the
|
|
25
|
+
* file copy), so the hook bridge and content rewriting resolve natively.
|
|
26
|
+
* Commands/agents/skills are ALREADY registered by GSD's native file copy in
|
|
27
|
+
* this mode, so the plugin's own config-hook registration is redundant and is
|
|
28
|
+
* SKIPPED (see IS_PACKAGE_TREE) to avoid double-registration.
|
|
29
|
+
*
|
|
30
|
+
* • Option 2 — package / git-spec. When loaded from the package tree (npm
|
|
31
|
+
* `main`, or an OpenCode git-spec install), REPO_ROOT is the package root and
|
|
32
|
+
* the source layout (commands/gsd/, agents/, skills/) is present. Here the
|
|
33
|
+
* plugin IS the sole registrar, so it registers commands/agents/skills too.
|
|
34
|
+
*
|
|
35
|
+
* IS_PACKAGE_TREE keys off the presence of the SOURCE command layout
|
|
36
|
+
* (commands/gsd/), which only exists in the package tree — never in an installed
|
|
37
|
+
* config dir (that uses the flattened command/ layout). The hook bridge and
|
|
38
|
+
* Read-time content rewriting run in BOTH modes; only the config-hook
|
|
39
|
+
* registration of commands/agents/skills is gated.
|
|
40
|
+
*
|
|
41
|
+
* Runtime-specific hooks are deliberately excluded:
|
|
42
|
+
* - gsd-statusline.js / gsd-update-banner.js (Claude Code statusline)
|
|
43
|
+
* - gsd-cursor-*.js (Cursor-specific)
|
|
44
|
+
* - *.sh scripts (invoked directly by commands/agents, not hook events)
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
"use strict";
|
|
48
|
+
|
|
49
|
+
const path = require("path");
|
|
50
|
+
const fs = require("fs");
|
|
51
|
+
const os = require("os");
|
|
52
|
+
const { spawnSync } = require("child_process");
|
|
53
|
+
|
|
54
|
+
// Resolve REPO_ROOT to the directory that actually holds the GSD payload
|
|
55
|
+
// (hooks/ + gsd-core/). This must work across three physical layouts because a
|
|
56
|
+
// single adapter file serves both distribution shapes (see header):
|
|
57
|
+
// • package/git-spec tree: <root>/.opencode/plugins/gsd-core.js → <root>
|
|
58
|
+
// • global file-copy: ~/.config/opencode/plugins/gsd-core.js → ~/.config/opencode
|
|
59
|
+
// • local file-copy: <proj>/.opencode/plugins/gsd-core.js → <proj>/.opencode
|
|
60
|
+
// A fixed "../.." only works for the first; the copied layouts sit one level
|
|
61
|
+
// shallower. Walking up to the first ancestor containing BOTH payload markers
|
|
62
|
+
// resolves all three deterministically. Falls back to the package-tree
|
|
63
|
+
// assumption ("../..") if no ancestor matches (keeps graceful degradation).
|
|
64
|
+
function resolveRepoRoot(startDir) {
|
|
65
|
+
let dir = startDir;
|
|
66
|
+
for (let i = 0; i < 6; i++) {
|
|
67
|
+
if (
|
|
68
|
+
fs.existsSync(path.join(dir, "hooks")) &&
|
|
69
|
+
fs.existsSync(path.join(dir, "gsd-core"))
|
|
70
|
+
) {
|
|
71
|
+
return dir;
|
|
72
|
+
}
|
|
73
|
+
const parent = path.dirname(dir);
|
|
74
|
+
if (parent === dir) break; // filesystem root
|
|
75
|
+
dir = parent;
|
|
76
|
+
}
|
|
77
|
+
// No ancestor carried both markers (broken/partial layout — the plugin can't
|
|
78
|
+
// function regardless). Fall back to the package-tree assumption ("../.."),
|
|
79
|
+
// matching the historical fixed-depth behavior and the .opencode/plugins/
|
|
80
|
+
// source layout.
|
|
81
|
+
return path.resolve(startDir, "../..");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// CJS: __dirname is a global, no need to derive from import.meta.url
|
|
85
|
+
const REPO_ROOT = resolveRepoRoot(__dirname);
|
|
86
|
+
const HOOKS_DIR = path.join(REPO_ROOT, "hooks");
|
|
87
|
+
const COMMANDS = path.join(REPO_ROOT, "commands", "gsd");
|
|
88
|
+
const AGENTS = path.join(REPO_ROOT, "agents");
|
|
89
|
+
const SKILLS = path.join(REPO_ROOT, "skills");
|
|
90
|
+
const GSD_CORE = path.join(REPO_ROOT, "gsd-core");
|
|
91
|
+
|
|
92
|
+
// True only when loaded from the package/source tree (Option 2), detected by the
|
|
93
|
+
// presence of the SOURCE command layout (commands/gsd/). In an installed OpenCode
|
|
94
|
+
// config dir (Option 1) this directory is absent — the flattened command/ layout
|
|
95
|
+
// is used instead — so the plugin skips its own command/agent/skill registration
|
|
96
|
+
// and lets GSD's native file copy own that surface (avoids double-registration).
|
|
97
|
+
const IS_PACKAGE_TREE = fs.existsSync(COMMANDS);
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Namespace conversion — reuse the single source of truth
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
let _cmdNames = null;
|
|
104
|
+
let _transformFn = null;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Lazily load scripts/fix-slash-commands.cjs and cache the transform function
|
|
108
|
+
* + command name list. Returns null if the module is unavailable (the plugin
|
|
109
|
+
* still works, just without namespace conversion).
|
|
110
|
+
*/
|
|
111
|
+
function getNamespaceConverter() {
|
|
112
|
+
if (_transformFn) return _transformFn;
|
|
113
|
+
try {
|
|
114
|
+
const mod = require(
|
|
115
|
+
path.join(REPO_ROOT, "scripts", "fix-slash-commands.cjs"),
|
|
116
|
+
);
|
|
117
|
+
_cmdNames = mod.readCmdNames();
|
|
118
|
+
_transformFn = mod.transformContentToHyphen;
|
|
119
|
+
return _transformFn;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Session state — tracked across plugin hook invocations
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
let currentSessionId = null;
|
|
130
|
+
let currentCwd = process.cwd();
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Tool name / argument mapping (OpenCode ↔ Claude Code)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
const TOOL_NAME_MAP = {
|
|
137
|
+
read: "Read",
|
|
138
|
+
write: "Write",
|
|
139
|
+
edit: "Edit",
|
|
140
|
+
apply_patch: "MultiEdit",
|
|
141
|
+
multi_edit: "MultiEdit",
|
|
142
|
+
bash: "Bash",
|
|
143
|
+
webfetch: "WebFetch",
|
|
144
|
+
web_search: "WebSearch",
|
|
145
|
+
websearch: "WebSearch",
|
|
146
|
+
task: "Task",
|
|
147
|
+
subagent: "Task",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function mapToolName(tool) {
|
|
151
|
+
if (!tool) return "";
|
|
152
|
+
return TOOL_NAME_MAP[String(tool).toLowerCase()] || tool;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build a Claude-style `tool_input` object from OpenCode's `output.args`.
|
|
156
|
+
function mapToolInput(args) {
|
|
157
|
+
const input = {};
|
|
158
|
+
if (!args || typeof args !== "object") return input;
|
|
159
|
+
|
|
160
|
+
// File-path keys (OpenCode uses filePath/path; Claude uses file_path)
|
|
161
|
+
const filePath = args.filePath || args.path || args.file_path;
|
|
162
|
+
if (filePath) input.file_path = filePath;
|
|
163
|
+
|
|
164
|
+
// Content for Write
|
|
165
|
+
if (args.content !== undefined) input.content = args.content;
|
|
166
|
+
|
|
167
|
+
// Edit patch fields
|
|
168
|
+
if (args.new_string !== undefined) input.new_string = args.new_string;
|
|
169
|
+
if (args.newString !== undefined) input.new_string = args.newString;
|
|
170
|
+
if (args.old_string !== undefined) input.old_string = args.old_string;
|
|
171
|
+
if (args.oldString !== undefined) input.old_string = args.oldString;
|
|
172
|
+
|
|
173
|
+
// Bash command
|
|
174
|
+
if (args.command !== undefined) input.command = args.command;
|
|
175
|
+
|
|
176
|
+
// Web
|
|
177
|
+
if (args.url !== undefined) input.url = args.url;
|
|
178
|
+
if (args.query !== undefined) input.query = args.query;
|
|
179
|
+
|
|
180
|
+
return input;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Hook subprocess runner
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Spawn a Claude Code hook script and pipe a JSON payload to its stdin.
|
|
189
|
+
*
|
|
190
|
+
* Hooks follow the convention:
|
|
191
|
+
* - stdout: JSON object (decision/advisory) or empty
|
|
192
|
+
* - exit 0: allow (with optional advisory JSON on stdout)
|
|
193
|
+
* - exit 2: block (Claude convention; reason in stdout JSON)
|
|
194
|
+
* - any error: exit 0 silently (hooks swallow their own errors)
|
|
195
|
+
*
|
|
196
|
+
* @param {string} hookFile filename under hooks/, e.g. "gsd-prompt-guard.js"
|
|
197
|
+
* @param {object} payload stdin JSON (hook_event_name, tool_name, ...)
|
|
198
|
+
* @param {object} [opts]
|
|
199
|
+
* @param {number} [opts.timeout=8000] spawn timeout in ms
|
|
200
|
+
* @param {string} [opts.cwd] working directory for the child
|
|
201
|
+
* @returns {{ stdout: string, exitCode: number, timedOut: boolean }}
|
|
202
|
+
*/
|
|
203
|
+
function runHook(hookFile, payload, opts = {}) {
|
|
204
|
+
const hookPath = path.join(HOOKS_DIR, hookFile);
|
|
205
|
+
if (!fs.existsSync(hookPath)) {
|
|
206
|
+
return { stdout: "", exitCode: 0, timedOut: false };
|
|
207
|
+
}
|
|
208
|
+
const timeout = opts.timeout ?? 8000;
|
|
209
|
+
let result;
|
|
210
|
+
try {
|
|
211
|
+
result = spawnSync(process.execPath, [hookPath], {
|
|
212
|
+
input: JSON.stringify(payload),
|
|
213
|
+
encoding: "utf8",
|
|
214
|
+
timeout,
|
|
215
|
+
cwd: opts.cwd || currentCwd,
|
|
216
|
+
windowsHide: true,
|
|
217
|
+
});
|
|
218
|
+
} catch {
|
|
219
|
+
// Spawn failure — never break the tool call
|
|
220
|
+
return { stdout: "", exitCode: 0, timedOut: false };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const stdout = (result.stdout || "").trim();
|
|
224
|
+
const exitCode = result.status == null ? 0 : result.status;
|
|
225
|
+
return { stdout, exitCode, timedOut: result.signal === "SIGTERM" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Hook output translation → OpenCode semantics
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Parse a hook's stdout and apply its effect to the OpenCode output object.
|
|
234
|
+
*
|
|
235
|
+
* - Block → throw Error(parsed.reason) so OpenCode aborts the tool call
|
|
236
|
+
* - Advisory→ append to output.metadata._gsdAdvisory[] and log to stderr
|
|
237
|
+
* - Silent → no-op
|
|
238
|
+
*
|
|
239
|
+
* @param {{ stdout: string, exitCode: number }} hookResult
|
|
240
|
+
* @param {object} [output] OpenCode mutable output object (optional)
|
|
241
|
+
*/
|
|
242
|
+
function handleHookResult(hookResult, output) {
|
|
243
|
+
const { stdout, exitCode } = hookResult;
|
|
244
|
+
if (!stdout && exitCode !== 2) return; // silent allow
|
|
245
|
+
|
|
246
|
+
let parsed = null;
|
|
247
|
+
if (stdout) {
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(stdout);
|
|
250
|
+
} catch {
|
|
251
|
+
// Non-JSON stdout (e.g. a stray log) — treat exit 2 as hard block, else allow
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Block: explicit decision OR Claude exit-code-2 convention
|
|
256
|
+
const isBlock = exitCode === 2 || (parsed && parsed.decision === "block");
|
|
257
|
+
if (isBlock) {
|
|
258
|
+
const reason =
|
|
259
|
+
(parsed && parsed.reason) || "Blocked by GSD hook (no reason provided).";
|
|
260
|
+
throw new Error(reason);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Advisory: inject additionalContext into metadata + log
|
|
264
|
+
const advisory =
|
|
265
|
+
parsed &&
|
|
266
|
+
parsed.hookSpecificOutput &&
|
|
267
|
+
parsed.hookSpecificOutput.additionalContext;
|
|
268
|
+
if (advisory) {
|
|
269
|
+
if (output) {
|
|
270
|
+
output.metadata = output.metadata || {};
|
|
271
|
+
// Accumulate: a single tool call can run several advisory hooks in
|
|
272
|
+
// sequence (prompt guard, read guard, worktree guard, workflow guard).
|
|
273
|
+
// Storing a scalar would let a later advisory clobber an earlier one, so
|
|
274
|
+
// collect them all.
|
|
275
|
+
if (!Array.isArray(output.metadata._gsdAdvisory)) {
|
|
276
|
+
output.metadata._gsdAdvisory = [];
|
|
277
|
+
}
|
|
278
|
+
output.metadata._gsdAdvisory.push(advisory);
|
|
279
|
+
}
|
|
280
|
+
// Best-effort visibility when metadata isn't surfaced to the model
|
|
281
|
+
console.error(advisory);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Frontmatter helpers (for config registration)
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function parseFrontmatter(content) {
|
|
290
|
+
const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
291
|
+
if (!m) return { frontmatter: {}, body: content };
|
|
292
|
+
const fm = {};
|
|
293
|
+
for (const line of m[1].split("\n")) {
|
|
294
|
+
const i = line.indexOf(":");
|
|
295
|
+
if (i > 0) {
|
|
296
|
+
let v = line.slice(i + 1).trim();
|
|
297
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
298
|
+
fm[line.slice(0, i).trim()] = v;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { frontmatter: fm, body: m[2] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Rewrite @~/.claude/ includes to point at the repo root.
|
|
305
|
+
// Also applies /gsd:xxx → /gsd-xxx namespace conversion via the shared
|
|
306
|
+
// transform from scripts/fix-slash-commands.cjs (single source of truth).
|
|
307
|
+
function rewriteRefs(content) {
|
|
308
|
+
let out = content.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
|
|
309
|
+
const transform = getNamespaceConverter();
|
|
310
|
+
if (transform && _cmdNames && _cmdNames.length) {
|
|
311
|
+
out = transform(out, _cmdNames);
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function loadDir(dir, keyFn, valFn) {
|
|
317
|
+
const result = {};
|
|
318
|
+
if (!fs.existsSync(dir)) return result;
|
|
319
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
320
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
|
321
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
322
|
+
result[keyFn(f)] = valFn(body, frontmatter, f);
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Runtime content transform — for Read tool results on GSD-managed files
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
// Directories whose .md files may contain ~/.claude/ paths and gsd: namespace
|
|
332
|
+
// refs. When the model reads these via the Read tool, we transparently rewrite
|
|
333
|
+
// both so OpenCode sees correct paths and hyphen-form command names.
|
|
334
|
+
const GSD_MANAGED_DIRS = [
|
|
335
|
+
path.join(GSD_CORE, "workflows"),
|
|
336
|
+
path.join(GSD_CORE, "references"),
|
|
337
|
+
path.join(GSD_CORE, "templates"),
|
|
338
|
+
path.join(GSD_CORE, "contexts"),
|
|
339
|
+
COMMANDS,
|
|
340
|
+
AGENTS,
|
|
341
|
+
SKILLS,
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
function isGsdManagedFile(filePath) {
|
|
345
|
+
if (!filePath) return false;
|
|
346
|
+
const resolved = path.resolve(filePath);
|
|
347
|
+
return GSD_MANAGED_DIRS.some(
|
|
348
|
+
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Rewrite content for OpenCode consumption:
|
|
353
|
+
// 1. @-include paths: @~/.claude/ → @<REPO_ROOT>/
|
|
354
|
+
// 2. plain-text paths: ~/.claude/gsd-core/ → <GSD_CORE>/
|
|
355
|
+
// 3. namespace: gsd:xxx → gsd-xxx (via fix-slash-commands.cjs)
|
|
356
|
+
function rewriteContent(content) {
|
|
357
|
+
let out = content;
|
|
358
|
+
out = out.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
|
|
359
|
+
out = out.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
|
|
360
|
+
const transform = getNamespaceConverter();
|
|
361
|
+
if (transform && _cmdNames && _cmdNames.length) {
|
|
362
|
+
out = transform(out, _cmdNames);
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Skills cache — copy SKILL.md files with rewritten @-include paths
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
//
|
|
371
|
+
// OpenCode's skill loader reads SKILL.md files directly from disk and resolves
|
|
372
|
+
// @-includes internally — this bypasses our tool.execute hooks. To make
|
|
373
|
+
// @~/.claude/gsd-core/... includes resolve, we copy all SKILL.md files to a
|
|
374
|
+
// cache directory with paths rewritten to the actual GSD_CORE location.
|
|
375
|
+
//
|
|
376
|
+
// Only used in package-tree mode (Option 2). In an installed OpenCode config
|
|
377
|
+
// dir (Option 1) skills are already staged + registered by GSD's native file
|
|
378
|
+
// copy, so we never register skills from the plugin (see IS_PACKAGE_TREE).
|
|
379
|
+
|
|
380
|
+
const SKILLS_CACHE = path.join(
|
|
381
|
+
os.homedir(),
|
|
382
|
+
".cache",
|
|
383
|
+
"opencode",
|
|
384
|
+
"gsd-skills",
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
function prepareSkillsCache() {
|
|
388
|
+
if (!fs.existsSync(SKILLS)) return null;
|
|
389
|
+
fs.mkdirSync(SKILLS_CACHE, { recursive: true });
|
|
390
|
+
for (const dir of fs.readdirSync(SKILLS)) {
|
|
391
|
+
const srcFile = path.join(SKILLS, dir, "SKILL.md");
|
|
392
|
+
if (!fs.existsSync(srcFile)) continue;
|
|
393
|
+
const raw = fs.readFileSync(srcFile, "utf8");
|
|
394
|
+
// Rewrite @-include paths only; namespace conversion is handled at
|
|
395
|
+
// Read-time via tool.execute.after for workflow/reference files.
|
|
396
|
+
const rewritten = raw
|
|
397
|
+
.replace(/@~\/\.claude\/gsd-core\//g, `@${GSD_CORE}/`)
|
|
398
|
+
.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
|
|
399
|
+
const destDir = path.join(SKILLS_CACHE, dir);
|
|
400
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
401
|
+
fs.writeFileSync(path.join(destDir, "SKILL.md"), rewritten);
|
|
402
|
+
}
|
|
403
|
+
return SKILLS_CACHE;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ===========================================================================
|
|
407
|
+
// Plugin entry
|
|
408
|
+
// ===========================================================================
|
|
409
|
+
|
|
410
|
+
const GsdCorePlugin = async ({ directory } = {}) => {
|
|
411
|
+
if (directory) currentCwd = directory;
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
// ── Config: register commands / agents / skills paths ──────────────
|
|
415
|
+
// Only in package-tree mode (Option 2). In an installed config dir
|
|
416
|
+
// (Option 1) GSD's native file copy already registered these, so the
|
|
417
|
+
// plugin stays out of registration to avoid double-registering.
|
|
418
|
+
config: async (config) => {
|
|
419
|
+
if (!IS_PACKAGE_TREE) return;
|
|
420
|
+
|
|
421
|
+
// Commands (commands/gsd/*.md → gsd-<name>)
|
|
422
|
+
config.command = config.command || {};
|
|
423
|
+
const cmds = loadDir(
|
|
424
|
+
COMMANDS,
|
|
425
|
+
(f) => "gsd-" + f.slice(0, -3),
|
|
426
|
+
(body, fm, name) => ({
|
|
427
|
+
template: rewriteRefs(body.trim()),
|
|
428
|
+
description: fm.description || `GSD ${name.slice(0, -3)} command`,
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
for (const [k, v] of Object.entries(cmds)) {
|
|
432
|
+
if (!config.command[k]) config.command[k] = v;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Agents (agents/*.md)
|
|
436
|
+
config.agent = config.agent || {};
|
|
437
|
+
const agents = loadDir(
|
|
438
|
+
AGENTS,
|
|
439
|
+
(f) => f.slice(0, -3),
|
|
440
|
+
(body, fm, name) => ({
|
|
441
|
+
prompt: rewriteRefs(body.trim()),
|
|
442
|
+
description: fm.description || `GSD ${name.slice(0, -3)} agent`,
|
|
443
|
+
mode: fm.mode || "subagent",
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
for (const [k, v] of Object.entries(agents)) {
|
|
447
|
+
if (!config.agent[k]) config.agent[k] = v;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Skills — copy SKILL.md files to cache with rewritten @-include paths,
|
|
451
|
+
// then register the cache directory. OpenCode's skill loader reads
|
|
452
|
+
// SKILL.md from disk and resolves @-includes internally (bypassing our
|
|
453
|
+
// tool.execute hooks), so we must pre-process the files.
|
|
454
|
+
const skillsCache = prepareSkillsCache();
|
|
455
|
+
config.skills = config.skills || {};
|
|
456
|
+
config.skills.paths = config.skills.paths || [];
|
|
457
|
+
const skillsPath = skillsCache || SKILLS;
|
|
458
|
+
if (!config.skills.paths.includes(skillsPath)) {
|
|
459
|
+
config.skills.paths.push(skillsPath);
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
// ── shell.env ───────────────────────────────────────────────────────
|
|
464
|
+
"shell.env": async (_input, output) => {
|
|
465
|
+
output.env = output.env || {};
|
|
466
|
+
output.env.GSD_DIR = GSD_CORE;
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
// ── tool.execute.before — PreToolUse hooks ─────────────────────────
|
|
470
|
+
"tool.execute.before": async (input, output) => {
|
|
471
|
+
const claudeTool = mapToolName(input.tool);
|
|
472
|
+
const toolInput = mapToolInput(output.args || {});
|
|
473
|
+
const cwd = currentCwd;
|
|
474
|
+
|
|
475
|
+
// 0. Read path rewrite — redirect ~/.claude/gsd-core/ to actual GSD_CORE
|
|
476
|
+
// so the model can read workflow/reference/template files that SKILL.md
|
|
477
|
+
// and command templates reference via the canonical Claude path.
|
|
478
|
+
if (claudeTool === "Read" && toolInput.file_path) {
|
|
479
|
+
const original = toolInput.file_path;
|
|
480
|
+
const rewritten = original
|
|
481
|
+
.replace(/^~\/\.claude\/gsd-core\//, GSD_CORE + "/")
|
|
482
|
+
.replace(/(?:.*)\/\.claude\/gsd-core\//, GSD_CORE + "/");
|
|
483
|
+
if (rewritten !== original) {
|
|
484
|
+
const args = output.args || {};
|
|
485
|
+
if (args.filePath) args.filePath = rewritten;
|
|
486
|
+
else if (args.path) args.path = rewritten;
|
|
487
|
+
else if (args.file_path) args.file_path = rewritten;
|
|
488
|
+
else args.filePath = rewritten;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const basePayload = {
|
|
493
|
+
hook_event_name: "PreToolUse",
|
|
494
|
+
cwd,
|
|
495
|
+
};
|
|
496
|
+
// NOTE: session_id intentionally omitted for PreToolUse hooks.
|
|
497
|
+
// gsd-read-guard.js treats a non-empty session_id as a Claude Code
|
|
498
|
+
// session and skips its advisory. On OpenCode we WANT the advisory.
|
|
499
|
+
const prePayload = (overrides = {}) => ({
|
|
500
|
+
...basePayload,
|
|
501
|
+
tool_name: claudeTool,
|
|
502
|
+
tool_input: toolInput,
|
|
503
|
+
...overrides,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const isWriteLike = ["Write", "Edit", "MultiEdit"].includes(claudeTool);
|
|
507
|
+
|
|
508
|
+
// 1. gsd-prompt-guard.js — injection scan on .planning/ writes
|
|
509
|
+
if (claudeTool === "Write" || claudeTool === "Edit") {
|
|
510
|
+
const r = runHook("gsd-prompt-guard.js", prePayload());
|
|
511
|
+
handleHookResult(r, output);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 2. gsd-read-guard.js — read-before-edit advisory
|
|
515
|
+
if (claudeTool === "Write" || claudeTool === "Edit") {
|
|
516
|
+
const r = runHook("gsd-read-guard.js", prePayload());
|
|
517
|
+
handleHookResult(r, output);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 3. gsd-worktree-path-guard.js — hard-block edits outside worktree
|
|
521
|
+
if (isWriteLike) {
|
|
522
|
+
const r = runHook("gsd-worktree-path-guard.js", prePayload());
|
|
523
|
+
handleHookResult(r, output);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// 4. gsd-workflow-guard.js — workflow advisory + git-force-add block
|
|
527
|
+
// (covers Write/Edit/MultiEdit AND Bash force-add detection)
|
|
528
|
+
if (isWriteLike || claudeTool === "Bash") {
|
|
529
|
+
const r = runHook("gsd-workflow-guard.js", prePayload());
|
|
530
|
+
handleHookResult(r, output);
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
// ── tool.execute.after — PostToolUse hooks ─────────────────────────
|
|
535
|
+
"tool.execute.after": async (input, output) => {
|
|
536
|
+
const claudeTool = mapToolName(input.tool);
|
|
537
|
+
// NOTE: In the `after` hook, `args` lives on `input` (not `output`).
|
|
538
|
+
// The `output` object only has { title, output, metadata }.
|
|
539
|
+
const toolInput = mapToolInput(input.args || {});
|
|
540
|
+
const cwd = currentCwd;
|
|
541
|
+
|
|
542
|
+
// GSD content transform — rewrite paths + namespace in Read results
|
|
543
|
+
// BEFORE injection scanning so the scanner sees the final content.
|
|
544
|
+
if (
|
|
545
|
+
claudeTool === "Read" &&
|
|
546
|
+
output.output &&
|
|
547
|
+
isGsdManagedFile(toolInput.file_path)
|
|
548
|
+
) {
|
|
549
|
+
const content =
|
|
550
|
+
typeof output.output === "string"
|
|
551
|
+
? output.output
|
|
552
|
+
: String(output.output);
|
|
553
|
+
output.output = rewriteContent(content);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// gsd-read-injection-scanner.js — scan Read/WebFetch/WebSearch results
|
|
557
|
+
if (
|
|
558
|
+
claudeTool === "Read" ||
|
|
559
|
+
claudeTool === "WebFetch" ||
|
|
560
|
+
claudeTool === "WebSearch"
|
|
561
|
+
) {
|
|
562
|
+
const payload = {
|
|
563
|
+
hook_event_name: "PostToolUse",
|
|
564
|
+
tool_name: claudeTool,
|
|
565
|
+
tool_input: toolInput,
|
|
566
|
+
tool_response: output.output,
|
|
567
|
+
cwd,
|
|
568
|
+
};
|
|
569
|
+
const r = runHook("gsd-read-injection-scanner.js", payload);
|
|
570
|
+
handleHookResult(r, output);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// gsd-context-monitor.js — context usage warnings (Bash/Edit/Write/Task/...)
|
|
575
|
+
// Only meaningful when a session_id is tracked (writes metrics sentinel).
|
|
576
|
+
if (currentSessionId) {
|
|
577
|
+
const payload = {
|
|
578
|
+
hook_event_name: "PostToolUse",
|
|
579
|
+
tool_name: claudeTool,
|
|
580
|
+
tool_input: toolInput,
|
|
581
|
+
session_id: currentSessionId,
|
|
582
|
+
cwd,
|
|
583
|
+
};
|
|
584
|
+
const r = runHook("gsd-context-monitor.js", payload);
|
|
585
|
+
handleHookResult(r, output);
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
// ── experimental.session.compacting — PreCompact ───────────────────
|
|
590
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
591
|
+
if (!currentSessionId) return;
|
|
592
|
+
const payload = {
|
|
593
|
+
hook_event_name: "PreCompact",
|
|
594
|
+
session_id: currentSessionId,
|
|
595
|
+
cwd: currentCwd,
|
|
596
|
+
};
|
|
597
|
+
const r = runHook("gsd-context-monitor.js", payload);
|
|
598
|
+
handleHookResult(r, output);
|
|
599
|
+
|
|
600
|
+
// Also inject a GSD compaction breadcrumb (mirrors the original plugin)
|
|
601
|
+
output.context = output.context || [];
|
|
602
|
+
output.context.push(
|
|
603
|
+
`[GSD] Active session: ${currentSessionId}. Preserve any in-flight phase/plan state.`,
|
|
604
|
+
);
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
// ── General event subscriptions ─────────────────────────────────────
|
|
608
|
+
event: async ({ event }) => {
|
|
609
|
+
// session.created → SessionStart hooks
|
|
610
|
+
if (event.type === "session.created") {
|
|
611
|
+
// Track session for context-monitor payloads.
|
|
612
|
+
// SDK type EventSessionCreated: { properties: { info: Session } }
|
|
613
|
+
// Session has `id` and `directory` (not `cwd`).
|
|
614
|
+
const info = event.properties?.info;
|
|
615
|
+
currentSessionId =
|
|
616
|
+
info?.id || event.sessionID || event.session_id || null;
|
|
617
|
+
if (info?.directory) currentCwd = info.directory;
|
|
618
|
+
|
|
619
|
+
// gsd-ensure-canonical-path.js — no stdin dependency; silent
|
|
620
|
+
runHook("gsd-ensure-canonical-path.js", {
|
|
621
|
+
hook_event_name: "SessionStart",
|
|
622
|
+
session_id: currentSessionId,
|
|
623
|
+
cwd: currentCwd,
|
|
624
|
+
});
|
|
625
|
+
// gsd-check-update.js — spawns its own background worker; no stdin
|
|
626
|
+
runHook("gsd-check-update.js", {
|
|
627
|
+
hook_event_name: "SessionStart",
|
|
628
|
+
session_id: currentSessionId,
|
|
629
|
+
cwd: currentCwd,
|
|
630
|
+
});
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// file.edited → FileChanged hook (config.json reload)
|
|
635
|
+
if (event.type === "file.edited") {
|
|
636
|
+
// SDK type EventFileEdited: { properties: { file: string } }
|
|
637
|
+
const filePath = event.properties?.file || event.filePath || "";
|
|
638
|
+
if (!filePath.endsWith("config.json")) return;
|
|
639
|
+
const cwd = event.properties?.cwd || currentCwd;
|
|
640
|
+
const expected = path.join(cwd, ".planning", "config.json");
|
|
641
|
+
if (path.resolve(filePath) !== path.resolve(expected)) return;
|
|
642
|
+
|
|
643
|
+
const payload = {
|
|
644
|
+
hook_event_name: "FileChanged",
|
|
645
|
+
file_path: filePath,
|
|
646
|
+
event: "change",
|
|
647
|
+
cwd,
|
|
648
|
+
};
|
|
649
|
+
const r = runHook("gsd-config-reload.js", payload);
|
|
650
|
+
// Advisory-only (additionalContext); surface to logs
|
|
651
|
+
handleHookResult(r);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// session.idle ↔ Claude Stop lifecycle point (#1682 Slice 1b/c).
|
|
656
|
+
// OpenCode fires session.idle when the run quiesces. GSD maps it to the
|
|
657
|
+
// Stop equivalent — the opencode-subset lifecycle peer of compaction
|
|
658
|
+
// (compaction preserves state across context-window summarization; idle
|
|
659
|
+
// marks end-of-turn). No-op sentinel today (GSD state is already
|
|
660
|
+
// persisted to .planning/), but it MUST be recognized so the declared
|
|
661
|
+
// opencode-subset surface is fully wired and a future Stop-class hook can
|
|
662
|
+
// attach without a plugin change.
|
|
663
|
+
if (event.type === "session.idle") {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// Export shape — verified against OpenCode's plugin loader source
|
|
671
|
+
// (packages/opencode/src/plugin). The loader imports this module and runs
|
|
672
|
+
// `for (const entry of Object.values(mod)) { getServerPlugin(entry) }`, where
|
|
673
|
+
// `getServerPlugin` accepts a bare function OR an object exposing a `.server`
|
|
674
|
+
// function, and THROWS `TypeError("Plugin export is not a function")` for
|
|
675
|
+
// anything else. So EVERY enumerable value the loader iterates must be a
|
|
676
|
+
// function or an object with `.server`.
|
|
677
|
+
//
|
|
678
|
+
// The subtlety: depending on how OpenCode's runtime (Node or Bun) imports a
|
|
679
|
+
// CommonJS file, `mod` may be the raw `module.exports` OR an ESM namespace of
|
|
680
|
+
// the form `{ default: module.exports, ...syntheticNamedExports }`. A plain
|
|
681
|
+
// `module.exports = { id: "gsd-core", server }` literal risks a string `id`
|
|
682
|
+
// appearing in `Object.values(mod)` (as a raw property, or as a lexer-
|
|
683
|
+
// synthesized named export) — which would trip the throw. Two defenses:
|
|
684
|
+
// 1. `id` is defined NON-ENUMERABLE, so it never appears in Object.values yet
|
|
685
|
+
// stays readable (via property access) for the loader's identity/dedup.
|
|
686
|
+
// 2. `module.exports` is assigned from a VARIABLE (not an object literal), so
|
|
687
|
+
// cjs-module-lexer cannot statically synthesize named exports from it —
|
|
688
|
+
// only `default` is exposed under ESM/Bun interop.
|
|
689
|
+
// Result: raw-CJS `Object.values` = `[server]`; ESM `Object.values` =
|
|
690
|
+
// `[{server, <id non-enum>}]` — both fully extractable. Test-only helpers hang
|
|
691
|
+
// off the `server` FUNCTION (`server._internals`), never as a sibling export.
|
|
692
|
+
GsdCorePlugin._internals = {
|
|
693
|
+
REPO_ROOT,
|
|
694
|
+
IS_PACKAGE_TREE,
|
|
695
|
+
mapToolName,
|
|
696
|
+
mapToolInput,
|
|
697
|
+
parseFrontmatter,
|
|
698
|
+
rewriteContent,
|
|
699
|
+
isGsdManagedFile,
|
|
700
|
+
handleHookResult,
|
|
701
|
+
GsdCorePlugin,
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const gsdCorePluginExport = { server: GsdCorePlugin };
|
|
705
|
+
Object.defineProperty(gsdCorePluginExport, "id", {
|
|
706
|
+
value: "gsd-core",
|
|
707
|
+
enumerable: false,
|
|
708
|
+
writable: false,
|
|
709
|
+
configurable: false,
|
|
710
|
+
});
|
|
711
|
+
module.exports = gsdCorePluginExport;
|