clean-room-skill 0.1.11 → 0.1.13

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +35 -8
  5. package/agents/clean-architect.md +7 -1
  6. package/agents/clean-implementer-verifier-shell.md +4 -0
  7. package/agents/clean-polish-reviewer.md +3 -0
  8. package/agents/clean-qa-editor.md +4 -0
  9. package/agents/contaminated-handoff-sanitizer.md +3 -0
  10. package/agents/contaminated-manager-verifier.md +10 -1
  11. package/agents/contaminated-source-analyst.md +8 -1
  12. package/bin/install.js +11 -1621
  13. package/docs/ARCHITECTURE.md +7 -1
  14. package/docs/HOOKS.md +14 -10
  15. package/docs/REFERENCE.md +31 -6
  16. package/examples/codex/.codex/agents/clean-architect.toml +7 -5
  17. package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
  18. package/examples/codex/.codex/agents/clean-qa-editor.toml +3 -2
  19. package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
  20. package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +10 -4
  21. package/examples/codex/.codex/agents/contaminated-source-analyst.toml +7 -3
  22. package/hooks/validate-json-schema.py +14 -0
  23. package/lib/bootstrap.cjs +5 -1
  24. package/lib/doctor.cjs +157 -5
  25. package/lib/hooks.cjs +18 -0
  26. package/lib/install-artifacts.cjs +178 -4
  27. package/lib/install-claude-plugin.cjs +374 -0
  28. package/lib/install-cli.cjs +99 -0
  29. package/lib/install-operations.cjs +376 -0
  30. package/lib/install-options.cjs +149 -0
  31. package/lib/install-runtime-selection.cjs +180 -0
  32. package/lib/install-status.cjs +292 -0
  33. package/lib/install-tui.cjs +359 -0
  34. package/lib/preflight-bootstrap.cjs +39 -0
  35. package/lib/preflight-cli.cjs +95 -0
  36. package/lib/preflight-constants.cjs +25 -0
  37. package/lib/preflight-output.cjs +37 -0
  38. package/lib/preflight-paths.cjs +67 -0
  39. package/lib/preflight-template.cjs +103 -0
  40. package/lib/preflight-validation.cjs +276 -0
  41. package/lib/preflight.cjs +18 -461
  42. package/lib/run-clean-artifacts.cjs +276 -0
  43. package/lib/run-cli.cjs +90 -0
  44. package/lib/run-constants.cjs +171 -0
  45. package/lib/run-controller.cjs +247 -0
  46. package/lib/run-coverage.cjs +350 -0
  47. package/lib/run-hooks.cjs +96 -0
  48. package/lib/run-manifest.cjs +111 -0
  49. package/lib/run-progress.cjs +160 -0
  50. package/lib/run-results.cjs +433 -0
  51. package/lib/run-roots.cjs +230 -0
  52. package/lib/run-stages.cjs +409 -0
  53. package/lib/run.cjs +4 -1998
  54. package/lib/runtime-layout.cjs +12 -5
  55. package/package.json +8 -2
  56. package/plugin.json +1 -1
  57. package/skills/attended/SKILL.md +2 -0
  58. package/skills/clean-room/SKILL.md +6 -6
  59. package/skills/clean-room/assets/coverage-ledger.schema.json +95 -0
  60. package/skills/clean-room/assets/task-manifest.schema.json +25 -0
  61. package/skills/clean-room/examples/contaminated-side/task-manifest.json +14 -2
  62. package/skills/clean-room/references/CONTROLLER-LOOP.md +5 -0
  63. package/skills/clean-room/references/PROCESS.md +12 -4
  64. package/skills/clean-room/references/SPEC-SCHEMA.md +11 -2
  65. package/skills/refocus/SKILL.md +2 -0
  66. package/skills/unattended/SKILL.md +2 -0
package/lib/doctor.cjs CHANGED
@@ -6,11 +6,16 @@ const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
7
 
8
8
  const { readJsonFile } = require('./fs-utils.cjs');
9
- const { CLEAN_ROOM_HOOKS, configPathForRuntime } = require('./hooks.cjs');
9
+ const {
10
+ CLEAN_ROOM_HOOKS,
11
+ configPathForRuntime,
12
+ hasManagedOpenCodePlugin,
13
+ pluginPathForRuntime,
14
+ } = require('./hooks.cjs');
10
15
  const { resolveRuntimeLayout } = require('./runtime-layout.cjs');
11
16
 
12
17
  const HOOK_MODES = new Set(['safe', 'strict']);
13
- const RUNTIMES = new Set(['codex', 'claude']);
18
+ const RUNTIMES = new Set(['codex', 'claude', 'opencode']);
14
19
  const MAX_SPAWN_OUTPUT_CHARS = 2000;
15
20
  const MAX_SPAWN_OUTPUT_BYTES = 256 * 1024;
16
21
  const DOCTOR_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_DOCTOR_TIMEOUT_MS', 10_000);
@@ -66,7 +71,7 @@ function parseDoctorArgs(argv) {
66
71
  }
67
72
  }
68
73
  if (!RUNTIMES.has(options.runtime)) {
69
- throw new Error('doctor --runtime must be codex or claude');
74
+ throw new Error('doctor --runtime must be codex, claude, or opencode');
70
75
  }
71
76
  if (!HOOK_MODES.has(options.hookMode)) {
72
77
  throw new Error('doctor --hooks must be safe or strict');
@@ -215,7 +220,7 @@ function smokeEnv(layout, tmpRoot, role) {
215
220
  }
216
221
 
217
222
  function runHookCommand(command, payload, env, cwd) {
218
- const parts = shellSplit(command);
223
+ const parts = commandParts(command);
219
224
  return spawnSync(parts[0], parts.slice(1), {
220
225
  cwd,
221
226
  env,
@@ -228,7 +233,7 @@ function runHookCommand(command, payload, env, cwd) {
228
233
  }
229
234
 
230
235
  function runHookCommandRaw(command, input, env, cwd) {
231
- const parts = shellSplit(command);
236
+ const parts = commandParts(command);
232
237
  return spawnSync(parts[0], parts.slice(1), {
233
238
  cwd,
234
239
  env,
@@ -240,6 +245,10 @@ function runHookCommandRaw(command, input, env, cwd) {
240
245
  });
241
246
  }
242
247
 
248
+ function commandParts(command) {
249
+ return Array.isArray(command) ? command : shellSplit(command);
250
+ }
251
+
243
252
  function spawnOutputSnippet(value) {
244
253
  const text = String(value || '').trim();
245
254
  if (!text) return null;
@@ -324,9 +333,152 @@ function assertStrictCoverage(entries) {
324
333
  }
325
334
  }
326
335
 
336
+ function hookCommandParts(wrapperPath, hookMode, checks) {
337
+ const parts = ['python3', wrapperPath, '--mode', hookMode];
338
+ for (const check of checks) {
339
+ parts.push('--check', check);
340
+ }
341
+ return parts;
342
+ }
343
+
344
+ function extractStringConstant(content, name) {
345
+ const match = content.match(new RegExp(`const\\s+${name}\\s*=\\s*("(?:\\\\.|[^"\\\\])*")`));
346
+ if (!match) {
347
+ throw new Error(`OpenCode plugin is missing ${name}`);
348
+ }
349
+ return JSON.parse(match[1]);
350
+ }
351
+
352
+ function assertOpenCodePlugin(layout, hookMode) {
353
+ const pluginPath = pluginPathForRuntime(layout.runtime, layout.targetRoot);
354
+ if (!pluginPath || !fs.existsSync(pluginPath)) {
355
+ throw new Error(`OpenCode plugin does not exist: ${pluginPath}`);
356
+ }
357
+ if (!hasManagedOpenCodePlugin(pluginPath)) {
358
+ throw new Error(`OpenCode plugin is not managed by clean-room-skill: ${pluginPath}`);
359
+ }
360
+ const content = fs.readFileSync(pluginPath, 'utf8');
361
+ if (!content.includes('"tool.execute.before"')) {
362
+ throw new Error('OpenCode plugin is missing tool.execute.before hook');
363
+ }
364
+ if (!content.includes('"tool.execute.after"')) {
365
+ throw new Error('OpenCode plugin is missing tool.execute.after hook');
366
+ }
367
+ if (!content.includes('shell: false')) {
368
+ throw new Error('OpenCode plugin must spawn hook checks with shell: false');
369
+ }
370
+ const wrapperPath = extractStringConstant(content, 'CLEAN_ROOM_HOOK_WRAPPER');
371
+ if (!path.isAbsolute(wrapperPath) || path.basename(wrapperPath) !== 'clean-room-hook.py') {
372
+ throw new Error('OpenCode plugin wrapper path is not absolute');
373
+ }
374
+ if (!fs.existsSync(wrapperPath)) {
375
+ throw new Error(`OpenCode plugin wrapper does not exist: ${wrapperPath}`);
376
+ }
377
+ const observedMode = extractStringConstant(content, 'CLEAN_ROOM_HOOK_MODE');
378
+ if (observedMode !== hookMode) {
379
+ throw new Error(`OpenCode plugin does not use --mode ${hookMode}`);
380
+ }
381
+ for (const required of CLEAN_ROOM_HOOKS) {
382
+ for (const check of required.checks) {
383
+ if (!content.includes(check)) {
384
+ throw new Error(`OpenCode plugin is missing check ${check}`);
385
+ }
386
+ }
387
+ }
388
+ return { pluginPath, wrapperPath };
389
+ }
390
+
391
+ function printOpenCodeCoverage(plugin, hookMode) {
392
+ console.log('clean-room OpenCode plugin coverage:');
393
+ console.log(' ok tool.execute.before shell/read/write');
394
+ console.log(' ok tool.execute.after write');
395
+ console.log(` wrapper: ${plugin.wrapperPath}`);
396
+ console.log(' unsupported surfaces: OpenCode tools that do not emit tool.execute.* events are not covered');
397
+ console.log(` strict required: ${hookMode === 'strict' ? 'yes' : 'no'}`);
398
+ }
399
+
400
+ function runOpenCodeDoctor(options, layout) {
401
+ const plugin = assertOpenCodePlugin(layout, options.hookMode);
402
+ const pathEnv = { PATH: process.env.PATH || '' };
403
+ if (options.coverage) {
404
+ printOpenCodeCoverage(plugin, options.hookMode);
405
+ }
406
+ const shellCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[0].checks);
407
+ const readCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[1].checks);
408
+ const writeCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[2].checks);
409
+ const postWriteCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[3].checks);
410
+
411
+ if (options.hookMode === 'safe') {
412
+ const safe = runHookCommandRaw(shellCommand, '', pathEnv, layout.targetRoot);
413
+ if (safe.status !== 0) {
414
+ throw new Error(`safe OpenCode hook did not no-op without clean-room env: ${describeSpawn(safe)}`);
415
+ }
416
+ assertHookFails(
417
+ shellCommand,
418
+ {},
419
+ { ...pathEnv, CLEAN_ROOM_HOOK_ENFORCE: '1' },
420
+ layout.targetRoot,
421
+ 'enforced safe OpenCode',
422
+ /environment check failed/
423
+ );
424
+ } else {
425
+ assertHookFails(shellCommand, {}, pathEnv, layout.targetRoot, 'strict OpenCode', /environment check failed/);
426
+ }
427
+
428
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clean-room-doctor-'));
429
+ try {
430
+ const cleanEnv = { ...pathEnv, ...smokeEnv(layout, tmpRoot, 'clean-architect') };
431
+ const qaEnv = {
432
+ ...pathEnv,
433
+ ...smokeEnv(layout, tmpRoot, 'clean-qa-editor'),
434
+ CLEAN_ROOM_ALLOW_AGENT3_SHELL: '1',
435
+ };
436
+ const sourceFile = path.join(cleanEnv.CLEAN_ROOM_SOURCE_ROOTS, 'secret.txt');
437
+ const cleanBadJson = path.join(cleanEnv.CLEAN_ROOM_CLEAN_ROOTS, 'behavior-spec.json');
438
+ fs.writeFileSync(sourceFile, 'secret\n');
439
+ fs.writeFileSync(cleanBadJson, '{\n');
440
+
441
+ assertHookFails(readCommand, {
442
+ tool_name: 'read',
443
+ tool: 'read',
444
+ tool_input: { filePath: sourceFile },
445
+ cwd: layout.targetRoot,
446
+ }, cleanEnv, layout.targetRoot, 'OpenCode read', /source-root/);
447
+ assertHookFails(writeCommand, {
448
+ tool_name: 'write',
449
+ tool: 'write',
450
+ tool_input: { filePath: sourceFile },
451
+ cwd: layout.targetRoot,
452
+ }, cleanEnv, layout.targetRoot, 'OpenCode write', /source-root/);
453
+ assertHookFails(shellCommand, {
454
+ tool_name: 'bash',
455
+ tool: 'bash',
456
+ tool_input: { cwd: qaEnv.CLEAN_ROOM_IMPLEMENTATION_ROOTS, command: `cat ${sourceFile}` },
457
+ cwd: qaEnv.CLEAN_ROOM_IMPLEMENTATION_ROOTS,
458
+ }, qaEnv, layout.targetRoot, 'OpenCode shell', /policy denied shell tool use|source-root/);
459
+ assertHookFails(postWriteCommand, {
460
+ tool_name: 'write',
461
+ tool: 'write',
462
+ tool_input: { filePath: cleanBadJson },
463
+ cwd: layout.targetRoot,
464
+ }, cleanEnv, layout.targetRoot, 'OpenCode post-write', /JSON parse failed/);
465
+ } finally {
466
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
467
+ }
468
+
469
+ console.log(`clean-room doctor passed for ${options.runtime}`);
470
+ console.log(` plugin: ${plugin.pluginPath}`);
471
+ console.log(' managed plugin hooks: tool.execute.before, tool.execute.after');
472
+ console.log(` mode: ${options.hookMode}`);
473
+ return { pluginPath: plugin.pluginPath, managedHooks: 2 };
474
+ }
475
+
327
476
  function runDoctor(argv) {
328
477
  const options = parseDoctorArgs(argv);
329
478
  const layout = resolveRuntimeLayout(options.runtime, options.scope, { configDir: options.configDir });
479
+ if (layout.hookRegistration === 'local-plugin') {
480
+ return runOpenCodeDoctor(options, layout);
481
+ }
330
482
  const configPath = configPathForRuntime(layout.runtime, layout.targetRoot);
331
483
  if (!configPath) {
332
484
  throw new Error(`doctor is not supported for ${layout.runtime}`);
package/lib/hooks.cjs CHANGED
@@ -7,6 +7,7 @@ const { readJsonFile, writeJsonFile } = require('./fs-utils.cjs');
7
7
 
8
8
  const HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'SessionStart']);
9
9
  const CLEAN_ROOM_HOOK_TIMEOUT_SECONDS = 30;
10
+ const OPENCODE_PLUGIN_MARKER = 'clean-room-skill-opencode-plugin-v1';
10
11
 
11
12
  const CLEAN_ROOM_HOOKS = [
12
13
  {
@@ -163,6 +164,20 @@ function hasManagedHookEntries(configPath) {
163
164
  return false;
164
165
  }
165
166
 
167
+ function pluginPathForRuntime(runtime, targetRoot) {
168
+ if (runtime === 'opencode') {
169
+ return path.join(targetRoot, 'plugins', 'clean-room.ts');
170
+ }
171
+ return null;
172
+ }
173
+
174
+ function hasManagedOpenCodePlugin(pluginPath) {
175
+ if (!pluginPath || !fs.existsSync(pluginPath)) {
176
+ return false;
177
+ }
178
+ return fs.readFileSync(pluginPath, 'utf8').includes(OPENCODE_PLUGIN_MARKER);
179
+ }
180
+
166
181
  function mergedHookConfig(configPath, entries) {
167
182
  const original = readJsonFile(configPath, {});
168
183
  if (!original || typeof original !== 'object' || Array.isArray(original)) {
@@ -231,6 +246,9 @@ module.exports = {
231
246
  CLEAN_ROOM_HOOK_TIMEOUT_SECONDS,
232
247
  configPathForRuntime,
233
248
  hasManagedHookEntries,
249
+ hasManagedOpenCodePlugin,
250
+ OPENCODE_PLUGIN_MARKER,
251
+ pluginPathForRuntime,
234
252
  removeHookEntries,
235
253
  mergeHookEntries,
236
254
  shellQuote,
@@ -7,6 +7,7 @@ const {
7
7
  listFiles,
8
8
  readJsonFile,
9
9
  } = require('./fs-utils.cjs');
10
+ const { OPENCODE_PLUGIN_MARKER } = require('./hooks.cjs');
10
11
  const { resolveRuntimeLayout } = require('./runtime-layout.cjs');
11
12
 
12
13
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
@@ -104,6 +105,168 @@ function generatePluginManifest() {
104
105
  return `${JSON.stringify(manifest, null, 2)}\n`;
105
106
  }
106
107
 
108
+ function generateOpenCodePlugin(layout, hookMode) {
109
+ const wrapperPath = path.join(layout.targetRoot, 'hooks', 'clean-room', 'clean-room-hook.py');
110
+ const mode = hookMode === 'strict' ? 'strict' : 'safe';
111
+ return `import { spawn } from "node:child_process"
112
+
113
+ const CLEAN_ROOM_OPENCODE_PLUGIN_MARKER = ${JSON.stringify(OPENCODE_PLUGIN_MARKER)}
114
+ const CLEAN_ROOM_HOOK_MODE = ${JSON.stringify(mode)}
115
+ const CLEAN_ROOM_HOOK_WRAPPER = ${JSON.stringify(wrapperPath)}
116
+ const CLEAN_ROOM_HOOK_PYTHON = process.env.CLEAN_ROOM_HOOK_PYTHON || "python3"
117
+ const CLEAN_ROOM_HOOK_TIMEOUT_MS = 30_000
118
+ const MAX_HOOK_OUTPUT_CHARS = 256 * 1024
119
+
120
+ const CHECKS = {
121
+ shell: ["require-clean-room-env.py", "deny-clean-room-shell.py"],
122
+ read: ["require-clean-room-env.py", "deny-clean-source-read.py"],
123
+ write: ["require-clean-room-env.py", "deny-contaminated-clean-write.py"],
124
+ postWrite: [
125
+ "require-clean-room-env.py",
126
+ "check-artifact-leakage.py",
127
+ "validate-json-schema.py",
128
+ "validate-handoff-package.py",
129
+ ],
130
+ }
131
+
132
+ const SHELL_TOOLS = new Set([
133
+ "bash",
134
+ "shell",
135
+ "powershell",
136
+ "terminal",
137
+ "exec_command",
138
+ "shell_command",
139
+ "writestdin",
140
+ "write_stdin",
141
+ ])
142
+
143
+ const READ_TOOLS = new Set([
144
+ "read",
145
+ "glob",
146
+ "grep",
147
+ "list",
148
+ "ls",
149
+ "lsp",
150
+ "notebookread",
151
+ "viewimage",
152
+ "view_image",
153
+ ])
154
+
155
+ const DIRECTORY_READ_TOOLS = new Set(["glob", "grep", "list", "ls", "lsp"])
156
+ const WRITE_TOOLS = new Set(["write", "edit", "multiedit", "notebookedit", "applypatch", "apply_patch"])
157
+ const PATH_KEYS = ["file_path", "filePath", "path", "notebook_path", "notebookPath"]
158
+
159
+ function normalizeTool(tool) {
160
+ return String(tool || "").toLowerCase().replace(/[^a-z0-9_]/g, "")
161
+ }
162
+
163
+ function objectArgs(value) {
164
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {}
165
+ return { ...value }
166
+ }
167
+
168
+ function cwdFor(args, directory, worktree) {
169
+ if (typeof args.cwd === "string" && args.cwd) return args.cwd
170
+ if (typeof directory === "string" && directory) return directory
171
+ if (typeof worktree === "string" && worktree) return worktree
172
+ return process.cwd()
173
+ }
174
+
175
+ function withDirectoryFallbackPath(tool, args, cwd) {
176
+ if (!DIRECTORY_READ_TOOLS.has(tool)) return args
177
+ if (PATH_KEYS.some((key) => typeof args[key] === "string" && args[key])) return args
178
+ return { ...args, path: cwd }
179
+ }
180
+
181
+ function hookPayload(input, args, directory, worktree) {
182
+ const tool = input?.tool
183
+ const normalized = normalizeTool(tool)
184
+ const cwd = cwdFor(args, directory, worktree)
185
+ return {
186
+ tool_name: tool,
187
+ tool,
188
+ tool_input: withDirectoryFallbackPath(normalized, args, cwd),
189
+ cwd,
190
+ opencode: {
191
+ sessionID: input?.sessionID,
192
+ callID: input?.callID,
193
+ },
194
+ }
195
+ }
196
+
197
+ function hookArgs(checks) {
198
+ const args = [CLEAN_ROOM_HOOK_WRAPPER, "--mode", CLEAN_ROOM_HOOK_MODE]
199
+ for (const check of checks) args.push("--check", check)
200
+ return args
201
+ }
202
+
203
+ function appendBounded(current, chunk) {
204
+ if (current.length >= MAX_HOOK_OUTPUT_CHARS) return current
205
+ return (current + String(chunk)).slice(0, MAX_HOOK_OUTPUT_CHARS)
206
+ }
207
+
208
+ function runHook(label, checks, payload) {
209
+ return new Promise((resolve, reject) => {
210
+ const child = spawn(CLEAN_ROOM_HOOK_PYTHON, hookArgs(checks), {
211
+ env: process.env,
212
+ shell: false,
213
+ stdio: ["pipe", "pipe", "pipe"],
214
+ })
215
+ let stdout = ""
216
+ let stderr = ""
217
+ let settled = false
218
+ const timer = setTimeout(() => {
219
+ settled = true
220
+ child.kill("SIGTERM")
221
+ reject(new Error(\`clean-room \${label} hook timed out after \${CLEAN_ROOM_HOOK_TIMEOUT_MS}ms\`))
222
+ }, CLEAN_ROOM_HOOK_TIMEOUT_MS)
223
+ child.stdout.on("data", (chunk) => {
224
+ stdout = appendBounded(stdout, chunk)
225
+ })
226
+ child.stderr.on("data", (chunk) => {
227
+ stderr = appendBounded(stderr, chunk)
228
+ })
229
+ child.on("error", (error) => {
230
+ if (settled) return
231
+ settled = true
232
+ clearTimeout(timer)
233
+ reject(error)
234
+ })
235
+ child.on("close", (status, signal) => {
236
+ if (settled) return
237
+ settled = true
238
+ clearTimeout(timer)
239
+ if (status === 0) {
240
+ resolve()
241
+ return
242
+ }
243
+ const detail = (stderr || stdout || \`status \${status}\${signal ? \`, signal \${signal}\` : ""}\`).trim()
244
+ reject(new Error(\`clean-room \${label} hook denied tool use: \${detail}\`))
245
+ })
246
+ child.stdin.end(JSON.stringify(payload))
247
+ })
248
+ }
249
+
250
+ export const CleanRoomPlugin = async ({ directory, worktree }) => {
251
+ return {
252
+ "tool.execute.before": async (input, output) => {
253
+ const tool = normalizeTool(input?.tool)
254
+ const payload = hookPayload(input, objectArgs(output?.args), directory, worktree)
255
+ if (SHELL_TOOLS.has(tool)) await runHook("shell", CHECKS.shell, payload)
256
+ if (READ_TOOLS.has(tool)) await runHook("read", CHECKS.read, payload)
257
+ if (WRITE_TOOLS.has(tool)) await runHook("write", CHECKS.write, payload)
258
+ },
259
+ "tool.execute.after": async (input) => {
260
+ const tool = normalizeTool(input?.tool)
261
+ if (!WRITE_TOOLS.has(tool)) return
262
+ const payload = hookPayload(input, objectArgs(input?.args), directory, worktree)
263
+ await runHook("post-write", CHECKS.postWrite, payload)
264
+ },
265
+ }
266
+ }
267
+ `;
268
+ }
269
+
107
270
  function addCommandWrappers(desired, artifact) {
108
271
  const skillsRoot = path.join(PACKAGE_ROOT, artifact.source);
109
272
  const entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
@@ -117,7 +280,15 @@ function addCommandWrappers(desired, artifact) {
117
280
  }
118
281
  }
119
282
 
120
- function addArtifact(desired, artifact) {
283
+ function shouldInstallArtifact(artifact, hookMode) {
284
+ if (!Array.isArray(artifact.hookModes)) return true;
285
+ return artifact.hookModes.includes(hookMode);
286
+ }
287
+
288
+ function addArtifact(desired, artifact, layout, hookMode) {
289
+ if (!shouldInstallArtifact(artifact, hookMode)) {
290
+ return;
291
+ }
121
292
  if (artifact.kind === 'skills' || artifact.kind === 'agents') {
122
293
  addTree(desired, artifact.source, artifact.destSubpath);
123
294
  return;
@@ -136,6 +307,10 @@ function addArtifact(desired, artifact) {
136
307
  desired.set(artifact.destSubpath, Buffer.from(generatePluginManifest(), 'utf8'));
137
308
  return;
138
309
  }
310
+ if (artifact.kind === 'opencode-plugin') {
311
+ desired.set(artifact.destSubpath, Buffer.from(generateOpenCodePlugin(layout, hookMode), 'utf8'));
312
+ return;
313
+ }
139
314
  throw new Error(`unsupported artifact kind: ${artifact.kind}`);
140
315
  }
141
316
 
@@ -147,12 +322,11 @@ function layoutFromInput(runtimeOrLayout, scope, configDir) {
147
322
  }
148
323
 
149
324
  function buildDesiredFiles(runtimeOrLayout, hookMode, scope = 'global', configDir = null) {
150
- // Retained for callers that still pass the install hook mode.
151
- void hookMode;
325
+ hookMode = hookMode || 'safe';
152
326
  const layout = layoutFromInput(runtimeOrLayout, scope, configDir);
153
327
  const desired = new Map();
154
328
  for (const artifact of layout.artifacts) {
155
- addArtifact(desired, artifact);
329
+ addArtifact(desired, artifact, layout, hookMode);
156
330
  }
157
331
  return desired;
158
332
  }