jorgex-stack 1.0.0

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/PRD.md +297 -0
  3. package/README.md +58 -0
  4. package/dist/cli.js +2894 -0
  5. package/package.json +51 -0
  6. package/stack/agents/README.md +33 -0
  7. package/stack/agents/backend-analyst.md +61 -0
  8. package/stack/agents/code-reviewer.md +66 -0
  9. package/stack/agents/code-simplifier.md +81 -0
  10. package/stack/agents/comment-fixer.md +54 -0
  11. package/stack/agents/docs-maintainer.md +88 -0
  12. package/stack/agents/engram.md +124 -0
  13. package/stack/agents/frontend-analyst.md +51 -0
  14. package/stack/agents/implementer.md +55 -0
  15. package/stack/agents/orchestrator.md +192 -0
  16. package/stack/agents/security-auditor.md +66 -0
  17. package/stack/agents/silent-failure-hunter.md +167 -0
  18. package/stack/agents/test-analyzer.md +91 -0
  19. package/stack/agents/tester.md +71 -0
  20. package/stack/agents/translator.md +101 -0
  21. package/stack/agents/type-design-analyzer.md +128 -0
  22. package/stack/commands/xreview.md +80 -0
  23. package/stack/config/defaults.json +37 -0
  24. package/stack/hooks/hooks.json +18 -0
  25. package/stack/mcp/servers.json +19 -0
  26. package/stack/plugins/opencode/engram.ts +378 -0
  27. package/stack/plugins/opencode/hooks.ts +766 -0
  28. package/stack/plugins/opencode/package.json +10 -0
  29. package/stack/plugins/opencode/worktree.ts +405 -0
  30. package/stack/scripts/post-pr-review.cjs +156 -0
  31. package/stack/skills/agent-browser/SKILL.md +55 -0
  32. package/stack/skills/agent-delegation/SKILL.md +58 -0
  33. package/stack/skills/deploy-to-vercel/SKILL.md +296 -0
  34. package/stack/skills/deploy-to-vercel/resources/deploy-codex.sh +301 -0
  35. package/stack/skills/deploy-to-vercel/resources/deploy.sh +301 -0
  36. package/stack/skills/diagnose/SKILL.md +117 -0
  37. package/stack/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  38. package/stack/skills/find-skills/SKILL.md +133 -0
  39. package/stack/skills/graphify/.graphify_version +1 -0
  40. package/stack/skills/graphify/SKILL.md +1319 -0
  41. package/stack/skills/mcp-builder/LICENSE.txt +202 -0
  42. package/stack/skills/mcp-builder/SKILL.md +236 -0
  43. package/stack/skills/mcp-builder/reference/evaluation.md +602 -0
  44. package/stack/skills/mcp-builder/reference/mcp_best_practices.md +249 -0
  45. package/stack/skills/mcp-builder/reference/node_mcp_server.md +970 -0
  46. package/stack/skills/mcp-builder/reference/python_mcp_server.md +719 -0
  47. package/stack/skills/mcp-builder/scripts/connections.py +151 -0
  48. package/stack/skills/mcp-builder/scripts/evaluation.py +373 -0
  49. package/stack/skills/mcp-builder/scripts/example_evaluation.xml +22 -0
  50. package/stack/skills/mcp-builder/scripts/requirements.txt +2 -0
  51. package/stack/skills/obsidian-cli/SKILL.md +106 -0
  52. package/stack/skills/obsidian-markdown/SKILL.md +196 -0
  53. package/stack/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  54. package/stack/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  55. package/stack/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  56. package/stack/skills/react-doctor/SKILL.md +19 -0
  57. package/stack/skills/skill-creator/LICENSE.txt +202 -0
  58. package/stack/skills/skill-creator/SKILL.md +485 -0
  59. package/stack/skills/skill-creator/agents/analyzer.md +274 -0
  60. package/stack/skills/skill-creator/agents/comparator.md +202 -0
  61. package/stack/skills/skill-creator/agents/grader.md +223 -0
  62. package/stack/skills/skill-creator/assets/eval_review.html +146 -0
  63. package/stack/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  64. package/stack/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  65. package/stack/skills/skill-creator/references/schemas.md +430 -0
  66. package/stack/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  67. package/stack/skills/skill-creator/scripts/generate_report.py +326 -0
  68. package/stack/skills/skill-creator/scripts/improve_description.py +248 -0
  69. package/stack/skills/skill-creator/scripts/package_skill.py +136 -0
  70. package/stack/skills/skill-creator/scripts/quick_validate.py +103 -0
  71. package/stack/skills/skill-creator/scripts/run_eval.py +310 -0
  72. package/stack/skills/skill-creator/scripts/run_loop.py +332 -0
  73. package/stack/skills/skill-creator/scripts/utils.py +47 -0
  74. package/stack/skills/supabase/SKILL.md +135 -0
  75. package/stack/skills/supabase/assets/feedback-issue-template.md +17 -0
  76. package/stack/skills/supabase/references/skill-feedback.md +17 -0
  77. package/stack/skills/supabase-postgres-best-practices/SKILL.md +64 -0
  78. package/stack/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
  79. package/stack/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
  80. package/stack/skills/supabase-postgres-best-practices/references/_template.md +34 -0
  81. package/stack/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
  82. package/stack/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
  83. package/stack/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
  84. package/stack/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
  85. package/stack/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
  86. package/stack/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
  87. package/stack/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
  88. package/stack/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
  89. package/stack/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
  90. package/stack/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
  91. package/stack/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
  92. package/stack/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
  93. package/stack/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
  94. package/stack/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
  95. package/stack/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
  96. package/stack/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
  97. package/stack/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
  98. package/stack/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
  99. package/stack/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
  100. package/stack/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
  101. package/stack/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
  102. package/stack/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
  103. package/stack/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
  104. package/stack/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
  105. package/stack/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
  106. package/stack/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
  107. package/stack/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
  108. package/stack/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
  109. package/stack/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
  110. package/stack/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
  111. package/stack/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
  112. package/stack/skills/tdd/SKILL.md +109 -0
  113. package/stack/skills/tdd/deep-modules.md +33 -0
  114. package/stack/skills/tdd/interface-design.md +31 -0
  115. package/stack/skills/tdd/mocking.md +59 -0
  116. package/stack/skills/tdd/refactoring.md +10 -0
  117. package/stack/skills/tdd/tests.md +61 -0
  118. package/stack/skills/to-issues/SKILL.md +83 -0
  119. package/stack/skills/to-prd/SKILL.md +72 -0
  120. package/stack/skills/work-lifecycle/SKILL.md +79 -0
  121. package/stack/skills/work-lifecycle/references/plan-template.md +185 -0
  122. package/stack/system-prompt/AGENTS.md +171 -0
  123. package/stack/system-prompt/engram-protocol.md +53 -0
  124. package/upstreams.json +96 -0
@@ -0,0 +1,766 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+
3
+ interface HookConfig {
4
+ [event: string]: unknown;
5
+ }
6
+
7
+ interface ScriptResult {
8
+ exitCode: number;
9
+ stdout: string;
10
+ stderr: string;
11
+ script: string;
12
+ scriptPath: string;
13
+ }
14
+
15
+ type EventName =
16
+ | "session.created"
17
+ | "server.connected"
18
+ | "tool.execute.before"
19
+ | "tool.execute.after";
20
+
21
+ const isAbsolutePath = (value: string) =>
22
+ /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("/");
23
+
24
+ const joinProjectPath = (directory: string, script: string) => {
25
+ const normalizedDirectory = directory.replace(/[\\/]+$/, "");
26
+ const normalizedScript = script.replace(/^[\\/]+/, "").replace(/\\/g, "/");
27
+ return `${normalizedDirectory}/${normalizedScript}`;
28
+ };
29
+
30
+ // Resolve the user-level OpenCode config directory (cross-platform).
31
+ const getGlobalConfigDir = (): string => {
32
+ const explicit = process.env.OPENCODE_CONFIG_DIR;
33
+ if (explicit) return explicit.replace(/[\\/]+$/, "");
34
+
35
+ const home =
36
+ process.env.HOME ||
37
+ process.env.USERPROFILE ||
38
+ (process.env.HOMEDRIVE && process.env.HOMEPATH
39
+ ? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
40
+ : "");
41
+
42
+ if (!home) return "";
43
+ return `${home.replace(/[\\/]+$/, "")}/.config/opencode`;
44
+ };
45
+
46
+ const runScript = async (
47
+ client: any,
48
+ directory: string,
49
+ script: string,
50
+ payload?: unknown,
51
+ ): Promise<ScriptResult> => {
52
+ // Scripts may be tagged as `<baseDir>\u0000<script>` so each is resolved
53
+ // against the config that declared it (global vs project). Untagged scripts
54
+ // fall back to the runtime directory.
55
+ let baseDir = directory;
56
+ let rawScript = script;
57
+ const sep = script.indexOf("\u0000");
58
+ if (sep !== -1) {
59
+ baseDir = script.slice(0, sep) || directory;
60
+ rawScript = script.slice(sep + 1);
61
+ }
62
+
63
+ const scriptPath = isAbsolutePath(rawScript)
64
+ ? rawScript
65
+ : joinProjectPath(baseDir, rawScript);
66
+ const cwd = baseDir;
67
+ const stdin = payload ? JSON.stringify(payload) : "";
68
+ const stdinSource = new Response(stdin);
69
+
70
+ let command: string[] | null = null;
71
+
72
+ // Resolve bash path: Bun's spawned process may not inherit Git Bash in PATH on Windows
73
+ const resolveBash = (): string => {
74
+ const fs = require("fs");
75
+ const candidates = [
76
+ "C:/Program Files/Git/usr/bin/bash.exe",
77
+ "C:/Program Files/Git/bin/bash.exe",
78
+ "C:/Program Files (x86)/Git/usr/bin/bash.exe",
79
+ process.env.BASH_PATH,
80
+ "bash",
81
+ ].filter(Boolean) as string[];
82
+ for (const candidate of candidates) {
83
+ try {
84
+ if (fs.existsSync(candidate)) return candidate;
85
+ } catch {
86
+ /* skip */
87
+ }
88
+ }
89
+ return "bash"; // fallback
90
+ };
91
+
92
+ if (scriptPath.endsWith(".ps1")) {
93
+ command = [
94
+ "powershell",
95
+ "-NoProfile",
96
+ "-ExecutionPolicy",
97
+ "Bypass",
98
+ "-File",
99
+ scriptPath,
100
+ ];
101
+ } else if (scriptPath.endsWith(".sh")) {
102
+ command = [resolveBash(), scriptPath];
103
+ } else if (
104
+ scriptPath.endsWith(".cjs") ||
105
+ scriptPath.endsWith(".js") ||
106
+ scriptPath.endsWith(".mjs")
107
+ ) {
108
+ command = ["node", scriptPath];
109
+ }
110
+
111
+ if (!command) {
112
+ await client.app.log({
113
+ body: {
114
+ service: "hooks",
115
+ level: "warn",
116
+ message: "Unsupported hook script extension",
117
+ extra: { script, scriptPath },
118
+ },
119
+ });
120
+ return { exitCode: 0, stdout: "", stderr: "", script, scriptPath };
121
+ }
122
+
123
+ try {
124
+ const activeWorktreePath = getPayloadWorktreePath(payload);
125
+ const spawnEnv = buildScriptEnv(scriptPath, activeWorktreePath);
126
+
127
+ const proc = (globalThis as any).Bun.spawn(command, {
128
+ stdin: stdinSource,
129
+ stdout: "pipe",
130
+ stderr: "pipe",
131
+ cwd,
132
+ env: spawnEnv,
133
+ });
134
+
135
+ const [stdout, stderr, exitCode] = await Promise.all([
136
+ new Response(proc.stdout).text(),
137
+ new Response(proc.stderr).text(),
138
+ proc.exited,
139
+ ]);
140
+
141
+ if (exitCode !== 0) {
142
+ await client.app.log({
143
+ body: {
144
+ service: "hooks",
145
+ level: "error",
146
+ message: "Hook script execution failed",
147
+ extra: {
148
+ script,
149
+ scriptPath,
150
+ exitCode,
151
+ stderr: stderr.trim(),
152
+ stdout: stdout.trim(),
153
+ },
154
+ },
155
+ });
156
+ }
157
+
158
+ return { exitCode, stdout, stderr, script, scriptPath };
159
+ } catch (error) {
160
+ await client.app.log({
161
+ body: {
162
+ service: "hooks",
163
+ level: "error",
164
+ message: "Hook script execution failed",
165
+ extra: {
166
+ script,
167
+ scriptPath,
168
+ error: error instanceof Error ? error.message : String(error),
169
+ },
170
+ },
171
+ });
172
+ return {
173
+ exitCode: 1,
174
+ stdout: "",
175
+ stderr: error instanceof Error ? error.message : String(error),
176
+ script,
177
+ scriptPath,
178
+ };
179
+ }
180
+ };
181
+
182
+ const extractScriptMessage = (text: string) => {
183
+ const trimmed = text.trim();
184
+ if (!trimmed) return "";
185
+
186
+ try {
187
+ const parsed = JSON.parse(trimmed);
188
+ if (typeof parsed?.additionalContext === "string") {
189
+ return parsed.additionalContext;
190
+ }
191
+ return trimmed;
192
+ } catch {
193
+ return trimmed;
194
+ }
195
+ };
196
+
197
+ const appendToolOutput = (output: any, messages: string[]) => {
198
+ if (messages.length === 0) return;
199
+ const section = messages.join("\n\n");
200
+ // Built-in tools (bash, edit, read, write) use output.output (string)
201
+ // MCP tools (github_*, supabase_*) use output.content (array of content blocks)
202
+ if ("output" in output && typeof output.output === "string") {
203
+ output.output = output.output ? `${output.output}\n\n${section}` : section;
204
+ } else if ("content" in output && Array.isArray(output.content)) {
205
+ // MCP content blocks: [{type: "text", text: "..."}]
206
+ output.content.push({ type: "text", text: `\n\n${section}` });
207
+ } else if ("content" in output && typeof output.content === "string") {
208
+ output.content = output.content
209
+ ? `${output.content}\n\n${section}`
210
+ : section;
211
+ } else {
212
+ // Fallback: set output as string
213
+ output.output = section;
214
+ }
215
+ };
216
+
217
+ const buildHookPayload = (
218
+ directory: string,
219
+ event: EventName,
220
+ input?: any,
221
+ activeWorktreePath?: string,
222
+ ) => {
223
+ const args = input?.args || {};
224
+ const worktreePath = args.activeWorktreePath || args.worktreePath || activeWorktreePath;
225
+
226
+ return {
227
+ event,
228
+ directory,
229
+ activeWorktreePath: worktreePath,
230
+ worktreePath,
231
+ tool: input?.tool,
232
+ args: worktreePath
233
+ ? {
234
+ ...args,
235
+ activeWorktreePath: worktreePath,
236
+ worktreePath,
237
+ }
238
+ : args,
239
+ };
240
+ };
241
+
242
+ const getRuntimeContext = async (
243
+ client: any,
244
+ fallbackDirectory: string,
245
+ fallbackWorktreePath?: string,
246
+ ) => {
247
+ try {
248
+ const pathInfo = await client.path.get();
249
+ const runtimeWorktreePath =
250
+ typeof pathInfo?.worktree === "string" && pathInfo.worktree
251
+ ? pathInfo.worktree
252
+ : fallbackWorktreePath;
253
+ const runtimeDirectory =
254
+ runtimeWorktreePath ||
255
+ (typeof pathInfo?.directory === "string" && pathInfo.directory
256
+ ? pathInfo.directory
257
+ : fallbackDirectory);
258
+
259
+ return {
260
+ directory: runtimeDirectory,
261
+ activeWorktreePath: runtimeWorktreePath,
262
+ };
263
+ } catch {
264
+ return {
265
+ directory: fallbackDirectory,
266
+ activeWorktreePath: fallbackWorktreePath,
267
+ };
268
+ }
269
+ };
270
+
271
+ const getPayloadWorktreePath = (payload: unknown) => {
272
+ if (!isPlainObject(payload)) return undefined;
273
+
274
+ const topLevel = payload.activeWorktreePath || payload.worktreePath;
275
+ if (typeof topLevel === "string" && topLevel) return topLevel;
276
+
277
+ const args = payload.args;
278
+ if (!isPlainObject(args)) return undefined;
279
+
280
+ const argPath = args.activeWorktreePath || args.worktreePath;
281
+ return typeof argPath === "string" && argPath ? argPath : undefined;
282
+ };
283
+
284
+ const buildScriptEnv = (scriptPath: string, activeWorktreePath?: string) => {
285
+ const env = Object.fromEntries(
286
+ Object.entries(process.env).filter((entry): entry is [string, string] =>
287
+ typeof entry[1] === "string",
288
+ ),
289
+ );
290
+
291
+ if (activeWorktreePath) {
292
+ env.OPENCODE_WORKTREE_PATH = activeWorktreePath;
293
+ } else {
294
+ delete env.OPENCODE_WORKTREE_PATH;
295
+ }
296
+
297
+ // Ensure Git coreutils (cat, grep, head, dirname, etc.) are in PATH for .sh
298
+ // scripts. Windows-only: rutas de Git-for-Windows y ";" es el separador de
299
+ // PATH de Windows (en POSIX es ":" y esto corrompería el PATH).
300
+ if (scriptPath.endsWith(".sh") && process.platform === "win32") {
301
+ const gitUsrBin = "C:/Program Files/Git/usr/bin";
302
+ const gitBin = "C:/Program Files/Git/bin";
303
+ const currentPath = process.env.PATH || "";
304
+ env.PATH = `${gitUsrBin};${gitBin};${currentPath}`;
305
+ }
306
+
307
+ return env;
308
+ };
309
+
310
+ const normalizeWorktreePath = (worktree: unknown) => {
311
+ if (typeof worktree === "string" && worktree) return worktree;
312
+
313
+ if (!isPlainObject(worktree)) return undefined;
314
+
315
+ const candidate = worktree.path || worktree.root || worktree.directory;
316
+ return typeof candidate === "string" && candidate ? candidate : undefined;
317
+ };
318
+
319
+ const valueType = (value: unknown) => {
320
+ if (Array.isArray(value)) return "array";
321
+ if (value === null) return "null";
322
+ return typeof value;
323
+ };
324
+
325
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
326
+ typeof value === "object" && value !== null && !Array.isArray(value);
327
+
328
+ const isStringArray = (value: unknown): value is string[] =>
329
+ Array.isArray(value) && value.every((item) => typeof item === "string");
330
+
331
+ const logInvalidHookConfig = async (
332
+ client: any,
333
+ details: {
334
+ event: EventName;
335
+ tool?: string;
336
+ trigger?: string;
337
+ value: unknown;
338
+ reason: string;
339
+ },
340
+ ) => {
341
+ await client.app.log({
342
+ body: {
343
+ service: "hooks",
344
+ level: "warn",
345
+ message: "Invalid hook config ignored",
346
+ extra: {
347
+ event: details.event,
348
+ tool: details.tool,
349
+ trigger: details.trigger,
350
+ reason: details.reason,
351
+ valueType: valueType(details.value),
352
+ },
353
+ },
354
+ });
355
+ };
356
+
357
+ const getEventConfig = async (
358
+ client: any,
359
+ config: HookConfig,
360
+ event: EventName,
361
+ ): Promise<Record<string, unknown>> => {
362
+ const eventConfig = config[event];
363
+ if (eventConfig === undefined) return {};
364
+ if (isPlainObject(eventConfig)) return eventConfig;
365
+
366
+ await logInvalidHookConfig(client, {
367
+ event,
368
+ value: eventConfig,
369
+ reason: "event config must be an object",
370
+ });
371
+ return {};
372
+ };
373
+
374
+ const getToolScripts = async (
375
+ client: any,
376
+ config: HookConfig,
377
+ event: EventName,
378
+ tool: string,
379
+ ): Promise<string[]> => {
380
+ const eventConfig = await getEventConfig(client, config, event);
381
+ const value = eventConfig[tool];
382
+ if (value === undefined) return [];
383
+
384
+ if (isStringArray(value)) return value;
385
+
386
+ if (tool === "bash" && isPlainObject(value)) {
387
+ return [];
388
+ }
389
+
390
+ await logInvalidHookConfig(client, {
391
+ event,
392
+ tool,
393
+ value,
394
+ reason: "tool entry must be an array of script paths",
395
+ });
396
+ return [];
397
+ };
398
+
399
+ const getBashTriggerScripts = async (
400
+ client: any,
401
+ config: HookConfig,
402
+ event: EventName,
403
+ command: string,
404
+ ): Promise<string[]> => {
405
+ const eventConfig = await getEventConfig(client, config, event);
406
+ const value = eventConfig["bash"];
407
+ if (value === undefined || isStringArray(value)) return [];
408
+
409
+ const normalizedCommand = (command || "").toLowerCase();
410
+
411
+ if (!isPlainObject(value)) {
412
+ await logInvalidHookConfig(client, {
413
+ event,
414
+ tool: "bash",
415
+ value,
416
+ reason: "bash entry must be an array or trigger map",
417
+ });
418
+ return [];
419
+ }
420
+
421
+ const scripts: string[] = [];
422
+ for (const [trigger, triggerScripts] of Object.entries(value)) {
423
+ if (!isStringArray(triggerScripts)) {
424
+ await logInvalidHookConfig(client, {
425
+ event,
426
+ tool: "bash",
427
+ trigger,
428
+ value: triggerScripts,
429
+ reason: "bash trigger entry must be an array of script paths",
430
+ });
431
+ continue;
432
+ }
433
+
434
+ const normalizedTrigger = trigger.toLowerCase();
435
+ if (trigger === "*" || normalizedCommand.includes(normalizedTrigger)) {
436
+ scripts.push(...triggerScripts);
437
+ }
438
+ }
439
+
440
+ return scripts;
441
+ };
442
+
443
+ const runScriptsAndCollectMessages = async (
444
+ client: any,
445
+ directory: string,
446
+ scripts: string[],
447
+ payload: unknown,
448
+ ): Promise<string[]> => {
449
+ const messages: string[] = [];
450
+ for (const script of scripts) {
451
+ const result = await runScript(client, directory, script, payload);
452
+ const stdoutMessage = extractScriptMessage(result.stdout);
453
+ const stderrMessage = extractScriptMessage(result.stderr);
454
+ if (stdoutMessage) messages.push(stdoutMessage);
455
+ if (stderrMessage) messages.push(stderrMessage);
456
+ }
457
+ return messages;
458
+ };
459
+
460
+ const runScripts = async (
461
+ client: any,
462
+ directory: string,
463
+ scripts: string[],
464
+ payload: unknown,
465
+ ) => {
466
+ for (const script of scripts) {
467
+ await runScript(client, directory, script, payload);
468
+ }
469
+ };
470
+
471
+ export const HooksPlugin: Plugin = async ({ client, directory, worktree }) => {
472
+ const initialWorktreePath = normalizeWorktreePath(worktree);
473
+
474
+ const globalConfigDir = getGlobalConfigDir();
475
+
476
+ const readHookFile = async (configPath: string): Promise<HookConfig | null> => {
477
+ try {
478
+ const content = await (globalThis as any).Bun.file(configPath).text();
479
+ return JSON.parse(content);
480
+ } catch (error) {
481
+ // Los errores de fs exponen code, no name: sin esto, cada proyecto sin
482
+ // hooks.json loguearía un warning espurio.
483
+ const code = (error as { code?: string })?.code;
484
+ if (error instanceof Error && error.name !== "ENOENT" && code !== "ENOENT") {
485
+ await client.app.log({
486
+ body: {
487
+ service: "hooks",
488
+ level: "warn",
489
+ message: "Failed to load hooks config",
490
+ extra: { configPath, error: error.message },
491
+ },
492
+ });
493
+ }
494
+ return null;
495
+ }
496
+ };
497
+
498
+ // Merge two hook configs. Scripts from both are concatenated per event/tool/trigger.
499
+ // Global scripts are resolved against the global config dir; project scripts against the project.
500
+ const mergeHookConfigs = (
501
+ base: HookConfig,
502
+ incoming: HookConfig,
503
+ ): HookConfig => {
504
+ const result: HookConfig = { ...base };
505
+
506
+ for (const [event, eventValue] of Object.entries(incoming)) {
507
+ if (!isPlainObject(eventValue)) {
508
+ if (result[event] === undefined) result[event] = eventValue;
509
+ continue;
510
+ }
511
+
512
+ const baseEvent = isPlainObject(result[event])
513
+ ? { ...(result[event] as Record<string, unknown>) }
514
+ : {};
515
+
516
+ for (const [tool, toolValue] of Object.entries(eventValue)) {
517
+ const baseTool = baseEvent[tool];
518
+
519
+ if (isStringArray(toolValue)) {
520
+ baseEvent[tool] = isStringArray(baseTool)
521
+ ? [...baseTool, ...toolValue]
522
+ : [...toolValue];
523
+ continue;
524
+ }
525
+
526
+ if (isPlainObject(toolValue)) {
527
+ const mergedTriggers: Record<string, unknown> = isPlainObject(baseTool)
528
+ ? { ...(baseTool as Record<string, unknown>) }
529
+ : {};
530
+ for (const [trigger, scripts] of Object.entries(toolValue)) {
531
+ const existing = mergedTriggers[trigger];
532
+ if (isStringArray(scripts)) {
533
+ mergedTriggers[trigger] = isStringArray(existing)
534
+ ? [...existing, ...scripts]
535
+ : [...scripts];
536
+ } else if (mergedTriggers[trigger] === undefined) {
537
+ mergedTriggers[trigger] = scripts;
538
+ }
539
+ }
540
+ baseEvent[tool] = mergedTriggers;
541
+ continue;
542
+ }
543
+
544
+ if (baseEvent[tool] === undefined) baseEvent[tool] = toolValue;
545
+ }
546
+
547
+ result[event] = baseEvent;
548
+ }
549
+
550
+ return result;
551
+ };
552
+
553
+ // Tag every script path with the base directory it must be resolved against.
554
+ // Global scripts use the global config dir; project scripts use the project dir.
555
+ const tagScriptsWithBase = (config: HookConfig, baseDir: string): HookConfig => {
556
+ const tagList = (scripts: string[]) =>
557
+ scripts.map((s) => `${baseDir}\u0000${s}`);
558
+
559
+ const result: HookConfig = {};
560
+ for (const [event, eventValue] of Object.entries(config)) {
561
+ if (!isPlainObject(eventValue)) {
562
+ result[event] = eventValue;
563
+ continue;
564
+ }
565
+ const newEvent: Record<string, unknown> = {};
566
+ for (const [tool, toolValue] of Object.entries(eventValue)) {
567
+ if (isStringArray(toolValue)) {
568
+ newEvent[tool] = tagList(toolValue);
569
+ } else if (isPlainObject(toolValue)) {
570
+ const newTriggers: Record<string, unknown> = {};
571
+ for (const [trigger, scripts] of Object.entries(toolValue)) {
572
+ newTriggers[trigger] = isStringArray(scripts)
573
+ ? tagList(scripts)
574
+ : scripts;
575
+ }
576
+ newEvent[tool] = newTriggers;
577
+ } else {
578
+ newEvent[tool] = toolValue;
579
+ }
580
+ }
581
+ result[event] = newEvent;
582
+ }
583
+ return result;
584
+ };
585
+
586
+ const loadConfig = async (configDirectory = directory): Promise<HookConfig> => {
587
+ // Project-level hooks: prefer the runtime directory, fall back to the plugin directory.
588
+ let projectConfig: HookConfig = {};
589
+ let projectBase = configDirectory;
590
+
591
+ const runtimeConfig = await readHookFile(
592
+ `${configDirectory}/.opencode/hooks.json`,
593
+ );
594
+ if (runtimeConfig) {
595
+ projectConfig = runtimeConfig;
596
+ projectBase = configDirectory;
597
+ } else if (configDirectory !== directory) {
598
+ const fallbackConfig = await readHookFile(
599
+ `${directory}/.opencode/hooks.json`,
600
+ );
601
+ if (fallbackConfig) {
602
+ projectConfig = fallbackConfig;
603
+ projectBase = directory;
604
+ }
605
+ }
606
+ projectConfig = tagScriptsWithBase(projectConfig, projectBase);
607
+
608
+ // Global user-level hooks (~/.config/opencode/hooks.json).
609
+ let globalConfig: HookConfig = {};
610
+ if (globalConfigDir) {
611
+ const raw = await readHookFile(`${globalConfigDir}/hooks.json`);
612
+ if (raw) globalConfig = tagScriptsWithBase(raw, globalConfigDir);
613
+ }
614
+
615
+ // Global first, then project (the project can add more scripts on top).
616
+ return mergeHookConfigs(globalConfig, projectConfig);
617
+ };
618
+
619
+ return {
620
+ "session.created": async () => {
621
+ const config = await loadConfig();
622
+ const scripts = await getToolScripts(
623
+ client,
624
+ config,
625
+ "session.created",
626
+ "*",
627
+ );
628
+ await runScripts(client, directory, scripts, {
629
+ event: "session.created",
630
+ directory,
631
+ activeWorktreePath: initialWorktreePath,
632
+ worktreePath: initialWorktreePath,
633
+ });
634
+ },
635
+
636
+ "server.connected": async () => {
637
+ const config = await loadConfig();
638
+ const scripts = await getToolScripts(
639
+ client,
640
+ config,
641
+ "server.connected",
642
+ "*",
643
+ );
644
+ const fallbackScripts =
645
+ scripts.length === 0
646
+ ? await getToolScripts(client, config, "session.created", "*")
647
+ : [];
648
+ await runScripts(client, directory, scripts, {
649
+ event: "server.connected",
650
+ directory,
651
+ activeWorktreePath: initialWorktreePath,
652
+ worktreePath: initialWorktreePath,
653
+ });
654
+ await runScripts(client, directory, fallbackScripts, {
655
+ event: "server.connected",
656
+ directory,
657
+ activeWorktreePath: initialWorktreePath,
658
+ worktreePath: initialWorktreePath,
659
+ });
660
+ },
661
+
662
+ "tool.execute.after": async (input: any, output: any) => {
663
+ const runtime = await getRuntimeContext(client, directory, initialWorktreePath);
664
+ const config = await loadConfig(runtime.directory);
665
+ const tool = (input.tool || "").toLowerCase();
666
+ const args = input.args || {};
667
+ const command = args.command || "";
668
+ const payload = buildHookPayload(
669
+ runtime.directory,
670
+ "tool.execute.after",
671
+ input,
672
+ runtime.activeWorktreePath,
673
+ );
674
+ const messages: string[] = [];
675
+
676
+ const toolScripts = await getToolScripts(
677
+ client,
678
+ config,
679
+ "tool.execute.after",
680
+ tool,
681
+ );
682
+ messages.push(
683
+ ...(await runScriptsAndCollectMessages(
684
+ client,
685
+ runtime.directory,
686
+ toolScripts,
687
+ payload,
688
+ )),
689
+ );
690
+
691
+ if (tool === "bash") {
692
+ const bashTriggerScripts = await getBashTriggerScripts(
693
+ client,
694
+ config,
695
+ "tool.execute.after",
696
+ command,
697
+ );
698
+ messages.push(
699
+ ...(await runScriptsAndCollectMessages(
700
+ client,
701
+ runtime.directory,
702
+ bashTriggerScripts,
703
+ payload,
704
+ )),
705
+ );
706
+ }
707
+
708
+ const wildcardScripts = await getToolScripts(
709
+ client,
710
+ config,
711
+ "tool.execute.after",
712
+ "*",
713
+ );
714
+ messages.push(
715
+ ...(await runScriptsAndCollectMessages(
716
+ client,
717
+ runtime.directory,
718
+ wildcardScripts,
719
+ payload,
720
+ )),
721
+ );
722
+
723
+ appendToolOutput(output, messages);
724
+ },
725
+
726
+ "tool.execute.before": async (input: any) => {
727
+ const runtime = await getRuntimeContext(client, directory, initialWorktreePath);
728
+ const config = await loadConfig(runtime.directory);
729
+ const tool = (input.tool || "").toLowerCase();
730
+ const args = input.args || {};
731
+ const command = args.command || "";
732
+ const payload = buildHookPayload(
733
+ runtime.directory,
734
+ "tool.execute.before",
735
+ input,
736
+ runtime.activeWorktreePath,
737
+ );
738
+
739
+ const toolScripts = await getToolScripts(
740
+ client,
741
+ config,
742
+ "tool.execute.before",
743
+ tool,
744
+ );
745
+ await runScripts(client, runtime.directory, toolScripts, payload);
746
+
747
+ if (tool === "bash") {
748
+ const bashTriggerScripts = await getBashTriggerScripts(
749
+ client,
750
+ config,
751
+ "tool.execute.before",
752
+ command,
753
+ );
754
+ await runScripts(client, runtime.directory, bashTriggerScripts, payload);
755
+ }
756
+
757
+ const wildcardScripts = await getToolScripts(
758
+ client,
759
+ config,
760
+ "tool.execute.before",
761
+ "*",
762
+ );
763
+ await runScripts(client, runtime.directory, wildcardScripts, payload);
764
+ },
765
+ };
766
+ };