@yagni-app/code 0.3.1 → 0.3.3

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 (65) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/crashReport.d.ts +12 -0
  3. package/dist/crashReport.js +28 -1
  4. package/dist/extension/crashReport.d.ts +18 -0
  5. package/dist/extension/crashReport.js +35 -2
  6. package/dist/extension/footer.d.ts +1 -1
  7. package/dist/extension/hooks.d.ts +111 -0
  8. package/dist/extension/hooks.js +666 -0
  9. package/dist/extension/index.d.ts +13 -6
  10. package/dist/extension/index.js +57 -7
  11. package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
  12. package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
  13. package/dist/extension/permission/dbReadPolicy.js +227 -0
  14. package/dist/extension/{execPolicy.js → permission/execPolicy.js} +99 -8
  15. package/dist/extension/{permission.d.ts → permission/gate.d.ts} +10 -3
  16. package/dist/extension/{permission.js → permission/gate.js} +156 -9
  17. package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
  18. package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
  19. package/dist/extension/permission/index.d.ts +14 -0
  20. package/dist/extension/permission/index.js +14 -0
  21. package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
  22. package/dist/extension/permission/packageManagerPolicy.js +170 -0
  23. package/dist/extension/pipeline/activityFeed.js +19 -5
  24. package/dist/extension/pipeline/checker.d.ts +99 -0
  25. package/dist/extension/pipeline/checker.js +238 -0
  26. package/dist/extension/pipeline/fanout.d.ts +116 -0
  27. package/dist/extension/pipeline/fanout.js +248 -0
  28. package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
  29. package/dist/extension/pipeline/fanoutBeats.js +86 -0
  30. package/dist/extension/pipeline/goCommand.d.ts +14 -0
  31. package/dist/extension/pipeline/goCommand.js +38 -1
  32. package/dist/extension/pipeline/headlessGo.d.ts +163 -0
  33. package/dist/extension/pipeline/headlessGo.js +333 -0
  34. package/dist/extension/pipeline/invocation.d.ts +31 -3
  35. package/dist/extension/pipeline/invocation.js +37 -3
  36. package/dist/extension/pipeline/mission.d.ts +55 -0
  37. package/dist/extension/pipeline/mission.js +70 -0
  38. package/dist/extension/pipeline/orchestrator.d.ts +48 -3
  39. package/dist/extension/pipeline/orchestrator.js +450 -9
  40. package/dist/extension/pipeline/personas.d.ts +16 -1
  41. package/dist/extension/pipeline/personas.js +118 -7
  42. package/dist/extension/pipeline/runSession.d.ts +45 -1
  43. package/dist/extension/pipeline/runState.d.ts +57 -12
  44. package/dist/extension/pipeline/runState.js +60 -18
  45. package/dist/extension/pipeline/runner.js +10 -1
  46. package/dist/extension/pipeline/stages.d.ts +84 -7
  47. package/dist/extension/pipeline/stages.js +166 -0
  48. package/dist/extension/pipeline/tierCap.d.ts +32 -0
  49. package/dist/extension/pipeline/tierCap.js +57 -0
  50. package/dist/extension/pipeline/types.d.ts +130 -1
  51. package/dist/extension/pipeline/types.js +17 -0
  52. package/dist/extension/pipeline/verify.d.ts +86 -3
  53. package/dist/extension/pipeline/verify.js +175 -6
  54. package/dist/extension/subagents.js +13 -0
  55. package/dist/extension/turnLog.d.ts +38 -0
  56. package/dist/extension/turnLog.js +93 -0
  57. package/dist/goHeadless.d.ts +75 -0
  58. package/dist/goHeadless.js +132 -0
  59. package/dist/paths.d.ts +9 -0
  60. package/dist/paths.js +12 -0
  61. package/dist/promptEnrichment.d.ts +1 -1
  62. package/dist/promptEnrichment.js +1 -1
  63. package/package.json +2 -2
  64. /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
  65. /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
@@ -0,0 +1,666 @@
1
+ /**
2
+ * Lifecycle hooks system — user-configurable event hooks (YAG-506).
3
+ *
4
+ * Reads hook config from `~/.yagni-code/config.json` (user) and
5
+ * `.yagni-code/config.json` (project), and fires user-defined shell commands
6
+ * at lifecycle events. The config format mirrors Claude Code's `settings.json`
7
+ * hooks shape so a user can copy-paste between them.
8
+ *
9
+ * Supported events (8): SessionStart, UserPromptSubmit, PreToolUse,
10
+ * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd.
11
+ *
12
+ * Exit code semantics (matching Claude Code / Codex):
13
+ * 0 = success; stdout parsed as JSON for structured decisions
14
+ * 2 = explicit block/deny (control events only); stderr = reason
15
+ * other = hook failure; decision ignored, warning surfaced, normal flow
16
+ *
17
+ * Fail-soft: a broken hook degrades to "no hook," never to "broken session."
18
+ */
19
+ import { spawn } from "node:child_process";
20
+ import { mkdirSync, appendFileSync, existsSync, readFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { homedir } from "node:os";
23
+ import { codeStateHome } from "./stateHome.js";
24
+ import { isDebug } from "./diagnostics.js";
25
+ const SUPPORTED_EVENTS = [
26
+ "SessionStart",
27
+ "UserPromptSubmit",
28
+ "PreToolUse",
29
+ "PostToolUse",
30
+ "PermissionRequest",
31
+ "PreCompact",
32
+ "PostCompact",
33
+ "SessionEnd",
34
+ ];
35
+ // ---------------------------------------------------------------------------
36
+ // Config loading
37
+ // ---------------------------------------------------------------------------
38
+ /** Read and merge hooks config from user and project files. Pure I/O, fail-soft. */
39
+ export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env = process.env) {
40
+ if (env.YAGNI_CODE_EVAL_MODE === "1")
41
+ return {};
42
+ const merged = {};
43
+ // User-level: ~/.yagni-code/config.json — always trusted (the user's own file)
44
+ const userPath = join(codeStateHome(null, env, userHome), "config.json");
45
+ mergeHooksFromFile(merged, userPath, "user");
46
+ // Project-level: .yagni-code/config.json — tagged for trust gating at execution time
47
+ const projectPath = join(cwd, ".yagni-code", "config.json");
48
+ mergeHooksFromFile(merged, projectPath, "project");
49
+ return merged;
50
+ }
51
+ function mergeHooksFromFile(merged, path, source) {
52
+ try {
53
+ if (!existsSync(path))
54
+ return;
55
+ const raw = readFileSync(path, "utf8");
56
+ const parsed = JSON.parse(raw);
57
+ const hooks = parsed?.hooks;
58
+ if (!hooks || typeof hooks !== "object")
59
+ return;
60
+ for (const [event, groups] of Object.entries(hooks)) {
61
+ if (!Array.isArray(groups))
62
+ continue;
63
+ if (!merged[event])
64
+ merged[event] = [];
65
+ for (const g of groups.filter(isValidHookGroup)) {
66
+ merged[event].push({ ...g, _source: source });
67
+ }
68
+ }
69
+ }
70
+ catch {
71
+ // Fail-soft: missing or malformed config is logged and skipped.
72
+ }
73
+ }
74
+ function isValidHookGroup(value) {
75
+ if (!value || typeof value !== "object")
76
+ return false;
77
+ const v = value;
78
+ if (!Array.isArray(v.hooks))
79
+ return false;
80
+ return v.hooks.every((h) => h && typeof h === "object" && h.type === "command" && typeof h.command === "string");
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Matcher
84
+ // ---------------------------------------------------------------------------
85
+ /**
86
+ * Check if a matcher matches a tool name. Matches Claude Code / Codex:
87
+ * - No matcher → matches everything
88
+ * - "*" → matches everything
89
+ * - Exact string or pipe-separated alternatives ("Edit|Write") → exact match
90
+ * - Contains regex chars → regex match
91
+ */
92
+ export function matchesMatcher(matcher, input) {
93
+ if (!matcher || matcher === "*" || matcher === "")
94
+ return true;
95
+ if (input === undefined)
96
+ return false;
97
+ // If the matcher is alphanumeric + pipe only, treat as case-insensitive exact match
98
+ // (Claude Code uses PascalCase tool names like "Bash"; pi uses lowercase "bash")
99
+ if (/^[A-Za-z0-9_|]+$/.test(matcher)) {
100
+ const lower = input.toLowerCase();
101
+ return matcher.split("|").some((m) => m.toLowerCase() === lower);
102
+ }
103
+ try {
104
+ return new RegExp(matcher).test(input);
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ const DEFAULT_TIMEOUT_MS = 10_000;
111
+ /** Execute a hook command with stdin JSON, timeout, and fail-soft. */
112
+ function execHook(command, inputJson, cwd, timeoutMs = DEFAULT_TIMEOUT_MS, env) {
113
+ return new Promise((resolve) => {
114
+ const started = Date.now();
115
+ let child;
116
+ try {
117
+ child = spawn("sh", ["-c", command], {
118
+ cwd,
119
+ stdio: ["pipe", "pipe", "pipe"],
120
+ env: { ...process.env, ...env },
121
+ timeout: 0, // we handle timeout ourselves for kill semantics
122
+ });
123
+ }
124
+ catch (err) {
125
+ resolve({
126
+ exitCode: null,
127
+ stdout: "",
128
+ stderr: "",
129
+ error: `spawn failed: ${err instanceof Error ? err.message : String(err)}`,
130
+ durationMs: Date.now() - started,
131
+ });
132
+ return;
133
+ }
134
+ let stdout = "";
135
+ let stderr = "";
136
+ let timedOut = false;
137
+ const timer = setTimeout(() => {
138
+ timedOut = true;
139
+ try {
140
+ child.kill("SIGKILL");
141
+ }
142
+ catch { }
143
+ }, timeoutMs);
144
+ child.stdout?.on("data", (d) => { stdout += d.toString(); });
145
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
146
+ child.on("error", (err) => {
147
+ clearTimeout(timer);
148
+ resolve({
149
+ exitCode: null,
150
+ stdout,
151
+ stderr,
152
+ error: `spawn error: ${err.message}`,
153
+ durationMs: Date.now() - started,
154
+ });
155
+ });
156
+ child.on("close", (code) => {
157
+ clearTimeout(timer);
158
+ resolve({
159
+ exitCode: code,
160
+ stdout,
161
+ stderr,
162
+ error: timedOut ? `hook timed out after ${Math.round(timeoutMs / 1000)}s` : null,
163
+ durationMs: Date.now() - started,
164
+ });
165
+ });
166
+ // Write stdin
167
+ try {
168
+ child.stdin?.write(inputJson);
169
+ child.stdin?.end();
170
+ }
171
+ catch {
172
+ // stdin write failed — let the process finish (it may handle it)
173
+ }
174
+ });
175
+ }
176
+ // ---------------------------------------------------------------------------
177
+ // Output parsing
178
+ // ---------------------------------------------------------------------------
179
+ function looksLikeJson(stdout) {
180
+ const trimmed = stdout.trimStart();
181
+ return trimmed.startsWith("{") || trimmed.startsWith("[");
182
+ }
183
+ function tryParseJson(stdout) {
184
+ const trimmed = stdout.trim();
185
+ if (!trimmed)
186
+ return null;
187
+ try {
188
+ const value = JSON.parse(trimmed);
189
+ return value && typeof value === "object" && !Array.isArray(value)
190
+ ? value
191
+ : null;
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ /** Extract permissionDecision from PreToolUse stdout JSON. */
198
+ export function parsePreToolUseOutput(stdout) {
199
+ const trimmed = stdout.trim();
200
+ if (!trimmed)
201
+ return null;
202
+ if (!looksLikeJson(trimmed))
203
+ return null;
204
+ const parsed = tryParseJson(trimmed);
205
+ if (!parsed)
206
+ return null;
207
+ const hso = parsed.hookSpecificOutput;
208
+ if (!hso)
209
+ return null;
210
+ const decision = hso.permissionDecision;
211
+ if (decision === "allow")
212
+ return { decision: "allow" };
213
+ if (decision === "deny") {
214
+ const reason = typeof hso.permissionDecisionReason === "string"
215
+ ? hso.permissionDecisionReason.trim()
216
+ : "";
217
+ return { decision: "deny", reason: reason || "blocked by PreToolUse hook" };
218
+ }
219
+ if (decision === "ask")
220
+ return { decision: "ask" };
221
+ return null;
222
+ }
223
+ /** Extract decision from PermissionRequest stdout JSON. */
224
+ export function parsePermissionRequestOutput(stdout) {
225
+ const trimmed = stdout.trim();
226
+ if (!trimmed)
227
+ return null;
228
+ if (!looksLikeJson(trimmed))
229
+ return null;
230
+ const parsed = tryParseJson(trimmed);
231
+ if (!parsed)
232
+ return null;
233
+ const hso = parsed.hookSpecificOutput;
234
+ if (!hso)
235
+ return null;
236
+ const decision = hso.decision;
237
+ if (!decision)
238
+ return null;
239
+ const behavior = decision.behavior;
240
+ if (behavior === "allow")
241
+ return { decision: "allow" };
242
+ if (behavior === "deny") {
243
+ const message = typeof decision.message === "string" ? decision.message.trim() : "";
244
+ return { decision: "deny", reason: message || "denied by PermissionRequest hook" };
245
+ }
246
+ return null;
247
+ }
248
+ /** Extract additionalContext from stdout JSON (for SessionStart, UserPromptSubmit, PostToolUse). */
249
+ export function parseAdditionalContext(stdout) {
250
+ const trimmed = stdout.trim();
251
+ if (!trimmed)
252
+ return null;
253
+ // Try JSON parse first
254
+ if (looksLikeJson(trimmed)) {
255
+ const parsed = tryParseJson(trimmed);
256
+ if (parsed) {
257
+ const hso = parsed.hookSpecificOutput;
258
+ if (hso && typeof hso.additionalContext === "string" && hso.additionalContext.trim()) {
259
+ return hso.additionalContext.trim();
260
+ }
261
+ // Also check top-level continue/stopReason for compact
262
+ if (parsed.continue === false) {
263
+ return null; // handled by compact-specific parsing
264
+ }
265
+ return null;
266
+ }
267
+ // Looks like JSON but failed to parse — invalid output, ignore
268
+ return null;
269
+ }
270
+ // Plain text stdout → context
271
+ return trimmed;
272
+ }
273
+ /** Check if stdout JSON has continue: false (for PreCompact cancellation). */
274
+ export function parseCompactCancel(stdout) {
275
+ const trimmed = stdout.trim();
276
+ if (!trimmed || !looksLikeJson(trimmed))
277
+ return false;
278
+ const parsed = tryParseJson(trimmed);
279
+ if (!parsed)
280
+ return false;
281
+ return parsed.continue === false;
282
+ }
283
+ // ---------------------------------------------------------------------------
284
+ // Diagnostic logging
285
+ // ---------------------------------------------------------------------------
286
+ function logHookEvent(env, payload) {
287
+ if (!isDebug(env))
288
+ return;
289
+ try {
290
+ if (process.env.NODE_TEST_CONTEXT)
291
+ return;
292
+ const logPath = join(codeStateHome(null, env), "logs", "hooks.log");
293
+ mkdirSync(dirname(logPath), { recursive: true });
294
+ appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
295
+ }
296
+ catch {
297
+ // logging must never break the session
298
+ }
299
+ }
300
+ /** Filter hook groups by workspace trust: project-level groups are skipped when untrusted. */
301
+ function filterByTrust(groups, isTrusted) {
302
+ if (isTrusted)
303
+ return groups;
304
+ return groups.filter((g) => g._source !== "project");
305
+ }
306
+ /**
307
+ * Create a HookRunner for injection into the permission gate. The `isTrusted`
308
+ * function is called per hook invocation to gate project-level hooks on
309
+ * workspace trust (pi's project trust model). Defaults to always-trusted.
310
+ */
311
+ export function makeHookRunner(opts) {
312
+ const { config, env = process.env, execImpl = execHook } = opts;
313
+ const isTrusted = opts.isTrusted ?? (() => true);
314
+ const preToolUseGroups = config["PreToolUse"] ?? [];
315
+ const permissionRequestGroups = config["PermissionRequest"] ?? [];
316
+ if (preToolUseGroups.length === 0 && permissionRequestGroups.length === 0)
317
+ return null;
318
+ async function runPreToolUseHooks(toolName, input, cwd, trusted) {
319
+ const sessionId = env.YAGNI_SESSION_ID ?? "";
320
+ const inputJson = JSON.stringify({
321
+ session_id: sessionId,
322
+ cwd,
323
+ tool_name: toolName,
324
+ tool_input: input,
325
+ hook_event_name: "PreToolUse",
326
+ });
327
+ for (const group of filterByTrust(preToolUseGroups, trusted ?? isTrusted())) {
328
+ if (!matchesMatcher(group.matcher, toolName))
329
+ continue;
330
+ for (const entry of group.hooks) {
331
+ const result = await execImpl(entry.command, inputJson, cwd, DEFAULT_TIMEOUT_MS, {
332
+ YAGNI_HOOK_EVENT: "PreToolUse",
333
+ YAGNI_HOOK_CWD: cwd,
334
+ YAGNI_HOOK_SESSION_ID: sessionId,
335
+ });
336
+ logHookEvent(env, {
337
+ event: "PreToolUse",
338
+ command: entry.command,
339
+ status: result.error ? "failed" : "completed",
340
+ exit_code: result.exitCode,
341
+ duration_ms: result.durationMs,
342
+ ...(result.error ? { error: result.error } : {}),
343
+ ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
344
+ });
345
+ if (result.error) {
346
+ // Spawn error / timeout → hook failure, continue
347
+ continue;
348
+ }
349
+ if (result.exitCode === 2) {
350
+ const reason = result.stderr.trim() || "blocked by PreToolUse hook";
351
+ return { decision: "deny", reason };
352
+ }
353
+ if (result.exitCode !== 0) {
354
+ // Other non-zero → hook failure, continue to next hook
355
+ continue;
356
+ }
357
+ // Exit 0 → parse stdout
358
+ const parsed = parsePreToolUseOutput(result.stdout);
359
+ if (parsed)
360
+ return parsed;
361
+ // No structured output → fall through to next hook
362
+ }
363
+ }
364
+ return null;
365
+ }
366
+ async function runPermissionRequestHooks(toolName, input, cwd, trusted) {
367
+ const sessionId = env.YAGNI_SESSION_ID ?? "";
368
+ const inputJson = JSON.stringify({
369
+ session_id: sessionId,
370
+ cwd,
371
+ tool_name: toolName,
372
+ tool_input: input,
373
+ hook_event_name: "PermissionRequest",
374
+ });
375
+ for (const group of filterByTrust(permissionRequestGroups, trusted ?? isTrusted())) {
376
+ if (!matchesMatcher(group.matcher, toolName))
377
+ continue;
378
+ for (const entry of group.hooks) {
379
+ const result = await execImpl(entry.command, inputJson, cwd, DEFAULT_TIMEOUT_MS, {
380
+ YAGNI_HOOK_EVENT: "PermissionRequest",
381
+ YAGNI_HOOK_CWD: cwd,
382
+ YAGNI_HOOK_SESSION_ID: sessionId,
383
+ });
384
+ logHookEvent(env, {
385
+ event: "PermissionRequest",
386
+ command: entry.command,
387
+ status: result.error ? "failed" : "completed",
388
+ exit_code: result.exitCode,
389
+ duration_ms: result.durationMs,
390
+ ...(result.error ? { error: result.error } : {}),
391
+ ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
392
+ });
393
+ if (result.error)
394
+ continue;
395
+ if (result.exitCode === 2) {
396
+ const reason = result.stderr.trim() || "denied by PermissionRequest hook";
397
+ return { decision: "deny", reason };
398
+ }
399
+ if (result.exitCode !== 0)
400
+ continue;
401
+ const parsed = parsePermissionRequestOutput(result.stdout);
402
+ if (parsed)
403
+ return parsed;
404
+ }
405
+ }
406
+ return null;
407
+ }
408
+ return {
409
+ preToolUse: runPreToolUseHooks,
410
+ permissionRequest: runPermissionRequestHooks,
411
+ };
412
+ }
413
+ /**
414
+ * Register pi event handlers for all configured lifecycle hooks.
415
+ * No-op if no hooks are configured or in eval mode.
416
+ */
417
+ export function registerHooks(pi, deps = {}) {
418
+ const env = deps.env ?? process.env;
419
+ if (env.YAGNI_CODE_EVAL_MODE === "1")
420
+ return;
421
+ const config = deps.config ?? loadHooksConfig();
422
+ const execImpl = deps.execImpl ?? execHook;
423
+ const sessionId = env.YAGNI_SESSION_ID ?? "";
424
+ // Fallback trust check for test environments where ctx.isProjectTrusted is unavailable.
425
+ const defaultTrusted = deps.isTrusted ?? (() => true);
426
+ const trusted = (ctx) => {
427
+ try {
428
+ return ctx.isProjectTrusted();
429
+ }
430
+ catch {
431
+ return defaultTrusted();
432
+ }
433
+ };
434
+ // --- SessionStart ---
435
+ const sessionStartGroups = config["SessionStart"] ?? [];
436
+ if (sessionStartGroups.length > 0) {
437
+ pi.on("session_start", async (event, ctx) => {
438
+ const cwd = ctx.cwd;
439
+ const inputJson = JSON.stringify({ session_id: sessionId, cwd, hook_event_name: "SessionStart" });
440
+ // The matcher for SessionStart hooks matches against pi's session start
441
+ // reason ("startup" | "reload" | "new" | "resume" | "fork"), matching
442
+ // Codex's SessionStart matcher input ("startup" | "resume" | "clear").
443
+ // A hook with no matcher or "*" runs on every session start.
444
+ for (const group of filterByTrust(sessionStartGroups, trusted(ctx))) {
445
+ if (!matchesMatcher(group.matcher, event.reason))
446
+ continue;
447
+ for (const entry of group.hooks) {
448
+ await runSideEffectHook(entry.command, inputJson, cwd, "SessionStart", ctx, env, execImpl);
449
+ }
450
+ }
451
+ });
452
+ }
453
+ // --- UserPromptSubmit (via "input" event, filtered to interactive + non-streaming) ---
454
+ const userPromptSubmitGroups = config["UserPromptSubmit"] ?? [];
455
+ if (userPromptSubmitGroups.length > 0) {
456
+ pi.on("input", async (event, ctx) => {
457
+ // Only fire on initial interactive prompts, not steering messages
458
+ if (event.source !== "interactive")
459
+ return { action: "continue" };
460
+ if (event.streamingBehavior !== undefined)
461
+ return { action: "continue" };
462
+ const cwd = process.cwd();
463
+ const inputJson = JSON.stringify({
464
+ session_id: sessionId,
465
+ cwd,
466
+ prompt: event.text,
467
+ hook_event_name: "UserPromptSubmit",
468
+ });
469
+ let combinedContext = "";
470
+ for (const group of filterByTrust(userPromptSubmitGroups, trusted(ctx))) {
471
+ for (const entry of group.hooks) {
472
+ const result = await execImpl(entry.command, inputJson, cwd, DEFAULT_TIMEOUT_MS, {
473
+ YAGNI_HOOK_EVENT: "UserPromptSubmit",
474
+ YAGNI_HOOK_CWD: cwd,
475
+ YAGNI_HOOK_SESSION_ID: sessionId,
476
+ });
477
+ logHookEvent(env, {
478
+ event: "UserPromptSubmit",
479
+ command: entry.command,
480
+ status: result.error ? "failed" : "completed",
481
+ exit_code: result.exitCode,
482
+ duration_ms: result.durationMs,
483
+ ...(result.error ? { error: result.error } : {}),
484
+ ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
485
+ });
486
+ if (result.error)
487
+ continue;
488
+ if (result.exitCode === 2) {
489
+ // Block the prompt — surface the reason to the user
490
+ const reason = result.stderr.trim() || "blocked by UserPromptSubmit hook";
491
+ if (ctx.hasUI) {
492
+ ctx.ui.notify(`Prompt blocked by hook: ${reason}`, "warning");
493
+ }
494
+ logHookEvent(env, {
495
+ event: "UserPromptSubmit",
496
+ command: entry.command,
497
+ status: "blocked",
498
+ exit_code: 2,
499
+ stderr: reason.slice(0, 512),
500
+ duration_ms: result.durationMs,
501
+ });
502
+ return { action: "handled" };
503
+ }
504
+ if (result.exitCode !== 0)
505
+ continue;
506
+ // Exit 0 → parse stdout for context
507
+ const context = parseAdditionalContext(result.stdout);
508
+ if (context)
509
+ combinedContext += (combinedContext ? "\n\n" : "") + context;
510
+ }
511
+ }
512
+ if (combinedContext) {
513
+ return { action: "transform", text: combinedContext + "\n\n" + event.text };
514
+ }
515
+ return { action: "continue" };
516
+ });
517
+ }
518
+ // --- PostToolUse (via "tool_result" event) ---
519
+ const postToolUseGroups = config["PostToolUse"] ?? [];
520
+ if (postToolUseGroups.length > 0) {
521
+ pi.on("tool_result", async (event, ctx) => {
522
+ const cwd = ctx.cwd;
523
+ const inputJson = JSON.stringify({
524
+ session_id: sessionId,
525
+ cwd,
526
+ tool_name: event.toolName,
527
+ tool_input: event.input,
528
+ tool_response: event.content,
529
+ hook_event_name: "PostToolUse",
530
+ });
531
+ let combinedContext = "";
532
+ for (const group of filterByTrust(postToolUseGroups, trusted(ctx))) {
533
+ if (!matchesMatcher(group.matcher, event.toolName))
534
+ continue;
535
+ for (const entry of group.hooks) {
536
+ const result = await execImpl(entry.command, inputJson, cwd, DEFAULT_TIMEOUT_MS, {
537
+ YAGNI_HOOK_EVENT: "PostToolUse",
538
+ YAGNI_HOOK_CWD: cwd,
539
+ YAGNI_HOOK_SESSION_ID: sessionId,
540
+ });
541
+ logHookEvent(env, {
542
+ event: "PostToolUse",
543
+ command: entry.command,
544
+ status: result.error ? "failed" : "completed",
545
+ exit_code: result.exitCode,
546
+ duration_ms: result.durationMs,
547
+ ...(result.error ? { error: result.error } : {}),
548
+ ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
549
+ });
550
+ if (result.error)
551
+ continue;
552
+ if (result.exitCode === 2) {
553
+ // Exit 2 on PostToolUse → feedback to model via stderr
554
+ const feedback = result.stderr.trim();
555
+ if (feedback)
556
+ combinedContext += (combinedContext ? "\n\n" : "") + feedback;
557
+ continue;
558
+ }
559
+ if (result.exitCode !== 0)
560
+ continue;
561
+ const context = parseAdditionalContext(result.stdout);
562
+ if (context)
563
+ combinedContext += (combinedContext ? "\n\n" : "") + context;
564
+ }
565
+ }
566
+ if (combinedContext) {
567
+ // Append to event.content, preserving modifications from prior tool_result
568
+ // handlers (recall.ts). Guard against undefined/non-array content.
569
+ const content = Array.isArray(event.content) ? event.content : [];
570
+ return { content: [...content, { type: "text", text: `\n\n${combinedContext}` }] };
571
+ }
572
+ return undefined;
573
+ });
574
+ }
575
+ // --- PreCompact (via "session_before_compact") ---
576
+ const preCompactGroups = config["PreCompact"] ?? [];
577
+ if (preCompactGroups.length > 0) {
578
+ pi.on("session_before_compact", async (_event, ctx) => {
579
+ const cwd = ctx.cwd;
580
+ const inputJson = JSON.stringify({ session_id: sessionId, cwd, hook_event_name: "PreCompact" });
581
+ for (const group of filterByTrust(preCompactGroups, trusted(ctx))) {
582
+ for (const entry of group.hooks) {
583
+ const result = await execImpl(entry.command, inputJson, cwd, DEFAULT_TIMEOUT_MS, {
584
+ YAGNI_HOOK_EVENT: "PreCompact",
585
+ YAGNI_HOOK_CWD: cwd,
586
+ YAGNI_HOOK_SESSION_ID: sessionId,
587
+ });
588
+ logHookEvent(env, {
589
+ event: "PreCompact",
590
+ command: entry.command,
591
+ status: result.error ? "failed" : "completed",
592
+ exit_code: result.exitCode,
593
+ duration_ms: result.durationMs,
594
+ ...(result.error ? { error: result.error } : {}),
595
+ });
596
+ if (result.error)
597
+ continue;
598
+ if (result.exitCode !== 0)
599
+ continue;
600
+ if (parseCompactCancel(result.stdout)) {
601
+ return { cancel: true };
602
+ }
603
+ }
604
+ }
605
+ return undefined;
606
+ });
607
+ }
608
+ // --- PostCompact (via "session_compact") ---
609
+ const postCompactGroups = config["PostCompact"] ?? [];
610
+ if (postCompactGroups.length > 0) {
611
+ pi.on("session_compact", async (_event, ctx) => {
612
+ const cwd = ctx.cwd;
613
+ const inputJson = JSON.stringify({ session_id: sessionId, cwd, hook_event_name: "PostCompact" });
614
+ for (const group of filterByTrust(postCompactGroups, trusted(ctx))) {
615
+ for (const entry of group.hooks) {
616
+ await runSideEffectHook(entry.command, inputJson, cwd, "PostCompact", ctx, env, execImpl);
617
+ }
618
+ }
619
+ });
620
+ }
621
+ // --- SessionEnd (via "session_shutdown") ---
622
+ const sessionEndGroups = config["SessionEnd"] ?? [];
623
+ if (sessionEndGroups.length > 0) {
624
+ pi.on("session_shutdown", async (_event, ctx) => {
625
+ const cwd = ctx.cwd;
626
+ const inputJson = JSON.stringify({ session_id: sessionId, cwd, hook_event_name: "SessionEnd" });
627
+ for (const group of filterByTrust(sessionEndGroups, trusted(ctx))) {
628
+ for (const entry of group.hooks) {
629
+ await runSideEffectHook(entry.command, inputJson, cwd, "SessionEnd", ctx, env, execImpl);
630
+ }
631
+ }
632
+ });
633
+ }
634
+ }
635
+ /** Run a side-effect-only hook (no control effects, output ignored). */
636
+ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, execImpl) {
637
+ try {
638
+ const result = await execImpl(command, inputJson, cwd, DEFAULT_TIMEOUT_MS, {
639
+ YAGNI_HOOK_EVENT: eventName,
640
+ YAGNI_HOOK_CWD: cwd,
641
+ YAGNI_HOOK_SESSION_ID: env.YAGNI_SESSION_ID ?? "",
642
+ });
643
+ logHookEvent(env, {
644
+ event: eventName,
645
+ command,
646
+ status: result.error ? "failed" : "completed",
647
+ exit_code: result.exitCode,
648
+ duration_ms: result.durationMs,
649
+ ...(result.error ? { error: result.error } : {}),
650
+ ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
651
+ });
652
+ if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) {
653
+ if (ctx.hasUI && result.stderr.trim()) {
654
+ ctx.ui.notify(`Hook '${eventName}' exited with code ${result.exitCode}: ${result.stderr.trim().slice(0, 200)}`, "warning");
655
+ }
656
+ }
657
+ }
658
+ catch {
659
+ // Fail-soft: a hook error never breaks the session
660
+ }
661
+ }
662
+ // ---------------------------------------------------------------------------
663
+ // Re-exports
664
+ // ---------------------------------------------------------------------------
665
+ export { SUPPORTED_EVENTS as HOOK_SUPPORTED_EVENTS };
666
+ //# sourceMappingURL=hooks.js.map