@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.
Files changed (74) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +4 -2
  27. package/bin/install.js +118 -1
  28. package/gemini-extension.json +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +18 -7
  30. package/gsd-core/bin/lib/capability-loader.cjs +27 -9
  31. package/gsd-core/bin/lib/capability-registry.cjs +51 -49
  32. package/gsd-core/bin/lib/capability-source.cjs +22 -7
  33. package/gsd-core/bin/lib/capability-validator.cjs +24 -2
  34. package/gsd-core/bin/lib/commands.cjs +2 -1
  35. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  36. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  37. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  38. package/gsd-core/bin/lib/host-integration.cjs +61 -0
  39. package/gsd-core/bin/lib/init.cjs +34 -6
  40. package/gsd-core/bin/lib/milestone.cjs +49 -10
  41. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  42. package/gsd-core/bin/lib/phase.cjs +37 -27
  43. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  44. package/gsd-core/bin/lib/probe-core.cjs +44 -4
  45. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  46. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  47. package/gsd-core/bin/lib/roadmap.cjs +28 -20
  48. package/gsd-core/bin/lib/state-transition.cjs +15 -0
  49. package/gsd-core/bin/lib/state.cjs +27 -8
  50. package/gsd-core/bin/lib/validate.cjs +2 -1
  51. package/gsd-core/bin/lib/verify.cjs +6 -4
  52. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  53. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  54. package/gsd-core/bin/shared/model-catalog.json +8 -8
  55. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  56. package/gsd-core/references/model-profiles.md +27 -0
  57. package/gsd-core/workflows/autonomous.md +22 -24
  58. package/gsd-core/workflows/complete-milestone.md +6 -10
  59. package/gsd-core/workflows/execute-phase.md +1 -1
  60. package/gsd-core/workflows/forensics.md +3 -3
  61. package/gsd-core/workflows/help/modes/full.md +1 -1
  62. package/gsd-core/workflows/milestone-summary.md +3 -3
  63. package/gsd-core/workflows/new-milestone.md +6 -0
  64. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  65. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  66. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  67. package/gsd-core/workflows/plan-phase.md +3 -158
  68. package/gsd-core/workflows/review.md +7 -2
  69. package/gsd-core/workflows/settings-advanced.md +10 -10
  70. package/gsd-core/workflows/verify-work.md +1 -2
  71. package/package.json +3 -1
  72. package/scripts/run-tests.cjs +51 -1
  73. package/scripts/sync-manifest-versions.cjs +66 -14
  74. 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;