@yagni-app/code-staging 0.3.2-staging.1112.1 → 0.3.2-staging.1114.1

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.
@@ -0,0 +1,111 @@
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ /** A single hook command entry, matching Claude Code's wire format. */
21
+ export interface HookEntry {
22
+ type: "command";
23
+ command: string;
24
+ }
25
+ /** A configured hook group with an optional matcher and source tag. */
26
+ export interface HookGroup {
27
+ matcher?: string;
28
+ hooks: HookEntry[];
29
+ /** Where this group was loaded from. Project-level groups are gated on workspace trust. */
30
+ _source?: "user" | "project";
31
+ }
32
+ /** The hooks section of config.json. */
33
+ export type HooksConfig = Record<string, HookGroup[]>;
34
+ /** Supported Claude Code event names. */
35
+ export type HookEventName = "SessionStart" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PermissionRequest" | "PreCompact" | "PostCompact" | "SessionEnd";
36
+ declare const SUPPORTED_EVENTS: readonly HookEventName[];
37
+ /** Result of a PreToolUse hook evaluation. */
38
+ export type PreToolUseHookResult = {
39
+ decision: "allow";
40
+ } | {
41
+ decision: "deny";
42
+ reason: string;
43
+ } | {
44
+ decision: "ask";
45
+ } | null;
46
+ /** Result of a PermissionRequest hook evaluation. */
47
+ export type PermissionRequestHookResult = {
48
+ decision: "allow";
49
+ } | {
50
+ decision: "deny";
51
+ reason: string;
52
+ } | null;
53
+ /** Interface injected into the permission gate. */
54
+ export interface HookRunner {
55
+ preToolUse(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PreToolUseHookResult>;
56
+ permissionRequest(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PermissionRequestHookResult>;
57
+ }
58
+ /** Read and merge hooks config from user and project files. Pure I/O, fail-soft. */
59
+ export declare function loadHooksConfig(userHome?: string, cwd?: string, env?: NodeJS.ProcessEnv): HooksConfig;
60
+ /**
61
+ * Check if a matcher matches a tool name. Matches Claude Code / Codex:
62
+ * - No matcher → matches everything
63
+ * - "*" → matches everything
64
+ * - Exact string or pipe-separated alternatives ("Edit|Write") → exact match
65
+ * - Contains regex chars → regex match
66
+ */
67
+ export declare function matchesMatcher(matcher: string | undefined, input: string | undefined): boolean;
68
+ interface ExecResult {
69
+ exitCode: number | null;
70
+ stdout: string;
71
+ stderr: string;
72
+ error: string | null;
73
+ durationMs: number;
74
+ }
75
+ /** Execute a hook command with stdin JSON, timeout, and fail-soft. */
76
+ declare function execHook(command: string, inputJson: string, cwd: string, timeoutMs?: number, env?: NodeJS.ProcessEnv): Promise<ExecResult>;
77
+ /** Extract permissionDecision from PreToolUse stdout JSON. */
78
+ export declare function parsePreToolUseOutput(stdout: string): PreToolUseHookResult;
79
+ /** Extract decision from PermissionRequest stdout JSON. */
80
+ export declare function parsePermissionRequestOutput(stdout: string): PermissionRequestHookResult;
81
+ /** Extract additionalContext from stdout JSON (for SessionStart, UserPromptSubmit, PostToolUse). */
82
+ export declare function parseAdditionalContext(stdout: string): string | null;
83
+ /** Check if stdout JSON has continue: false (for PreCompact cancellation). */
84
+ export declare function parseCompactCancel(stdout: string): boolean;
85
+ interface HookExecutorOptions {
86
+ config: HooksConfig;
87
+ env?: NodeJS.ProcessEnv;
88
+ execImpl?: typeof execHook;
89
+ /** Returns true when the workspace is trusted (pi's project trust model). */
90
+ isTrusted?: () => boolean;
91
+ }
92
+ /**
93
+ * Create a HookRunner for injection into the permission gate. The `isTrusted`
94
+ * function is called per hook invocation to gate project-level hooks on
95
+ * workspace trust (pi's project trust model). Defaults to always-trusted.
96
+ */
97
+ export declare function makeHookRunner(opts: HookExecutorOptions): HookRunner | null;
98
+ export interface RegisterHooksDeps {
99
+ config?: HooksConfig;
100
+ env?: NodeJS.ProcessEnv;
101
+ execImpl?: typeof execHook;
102
+ /** Returns true when the workspace is trusted (pi's project trust model). */
103
+ isTrusted?: () => boolean;
104
+ }
105
+ /**
106
+ * Register pi event handlers for all configured lifecycle hooks.
107
+ * No-op if no hooks are configured or in eval mode.
108
+ */
109
+ export declare function registerHooks(pi: ExtensionAPI, deps?: RegisterHooksDeps): void;
110
+ export { SUPPORTED_EVENTS as HOOK_SUPPORTED_EVENTS };
111
+ //# sourceMappingURL=hooks.d.ts.map
@@ -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
@@ -138,6 +138,8 @@ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace,
138
138
  export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutcome, RepoIntake, RepoIntakeFs, EngineeringDraft, DraftTeam, } from "./initPass.js";
139
139
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
140
140
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
141
+ export { loadHooksConfig, makeHookRunner, registerHooks, matchesMatcher, parsePreToolUseOutput, parsePermissionRequestOutput, parseAdditionalContext, parseCompactCancel, HOOK_SUPPORTED_EVENTS, } from "./hooks.js";
142
+ export type { HookEntry, HookGroup, HooksConfig, HookEventName, HookRunner, PreToolUseHookResult, PermissionRequestHookResult, RegisterHooksDeps, } from "./hooks.js";
141
143
  export { createUltraHolder, registerUltraCommand } from "./ultra.js";
142
144
  export type { UltraHolder } from "./ultra.js";
143
145
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
@@ -28,6 +28,7 @@ import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, register
28
28
  import { registerGoCommand } from "./pipeline/goCommand.js";
29
29
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
30
30
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
31
+ import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
31
32
  import { registerSubagents } from "./subagents.js";
32
33
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
33
34
  import { registerTodos } from "./todos.js";
@@ -280,6 +281,12 @@ export async function registerYagni(pi, deps = {}) {
280
281
  // rejected as a same-session self-authorization path, PR #1698).
281
282
  const sessionGrants = evalMode ? [] : loadGrants();
282
283
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
284
+ // YAG-506: load user-configurable lifecycle hooks config and create the
285
+ // hook runner for the permission gate. Skipped in eval mode.
286
+ const hooksConfig = evalMode ? {} : loadHooksConfig();
287
+ const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
288
+ if (!evalMode)
289
+ registerHooks(pi, { config: hooksConfig });
283
290
  registerPermissionGate(pi, {
284
291
  modeHolder,
285
292
  guardianState,
@@ -362,6 +369,7 @@ export async function registerYagni(pi, deps = {}) {
362
369
  onBlessRemember: decisionCapture
363
370
  ? (ctx, info) => decisionCapture.captureFromBless(ctx, info)
364
371
  : undefined,
372
+ ...(hookRunner ? { hookRunner } : {}),
365
373
  });
366
374
  // P2/YAG-383: /cost prefers the server-authoritative session spend (covers
367
375
  // subagents and advisor consults directly, since they bill under this same
@@ -859,6 +867,8 @@ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace,
859
867
  // Onramp Door B (F2a): the one-time init-pass idempotency marker.
860
868
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
861
869
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
870
+ // YAG-506: user-configurable lifecycle hooks.
871
+ export { loadHooksConfig, makeHookRunner, registerHooks, matchesMatcher, parsePreToolUseOutput, parsePermissionRequestOutput, parseAdditionalContext, parseCompactCancel, HOOK_SUPPORTED_EVENTS, } from "./hooks.js";
862
872
  // Ultra mode (/ultra): the aggressive fan-out/verify/synthesize dial.
863
873
  export { createUltraHolder, registerUltraCommand } from "./ultra.js";
864
874
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
30
  import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
31
+ import type { HookRunner } from "./hooks.js";
31
32
  import { type BlessStore } from "./bless.js";
32
33
  import { type ExecPolicy } from "./execPolicy.js";
33
34
  import { type GuardianError, type GuardianRiskLevel } from "./guardian.js";
@@ -179,6 +180,12 @@ export interface RegisterPermissionDeps {
179
180
  * Fail-soft; never blocks.
180
181
  */
181
182
  onGuardianEvent?: (event: GuardianGateEvent) => void;
183
+ /**
184
+ * User-configurable lifecycle hooks (YAG-506). When present, PreToolUse
185
+ * hooks run before decideGate and can short-circuit (allow/deny/ask),
186
+ * and PermissionRequest hooks run before the confirm dialog.
187
+ */
188
+ hookRunner?: HookRunner;
182
189
  }
183
190
  /** The customType tag on injected mode-context messages (filterable later). */
184
191
  export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
@@ -296,6 +296,7 @@ export function registerPermissionGate(pi, deps = {}) {
296
296
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
297
297
  let mode = deps.mode ?? "auto";
298
298
  const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
299
+ const hookRunner = deps.hookRunner;
299
300
  deps.modeHolder?.onSet((m) => {
300
301
  if (m !== mode)
301
302
  approvedCommands.clear();
@@ -422,7 +423,48 @@ export function registerPermissionGate(pi, deps = {}) {
422
423
  const modeAtEntry = mode;
423
424
  try {
424
425
  const input = event.input ?? {};
425
- const decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
426
+ // YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
427
+ // (allow/deny/ask) or fall through to the normal gate logic. The result
428
+ // is cached in preToolUseResult so the "ask" check below does NOT
429
+ // re-invoke the hook (hooks have side effects — notifications etc.).
430
+ let preToolUseResult;
431
+ if (hookRunner) {
432
+ const cwd = ctx?.cwd ?? ".";
433
+ try {
434
+ preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
435
+ if (preToolUseResult) {
436
+ if (preToolUseResult.decision === "deny") {
437
+ return { block: true, reason: preToolUseResult.reason };
438
+ }
439
+ if (preToolUseResult.decision === "allow") {
440
+ // Allow bypasses Guardian/confirm, but the exec policy's forbidden
441
+ // band still runs as a hard safety floor (deliberate deviation
442
+ // from Claude Code: we don't let a hook auto-allow a forbidden cmd).
443
+ if (event.toolName === "bash") {
444
+ const cmdRaw = input.command;
445
+ const command = typeof cmdRaw === "string" ? cmdRaw.trim() : "";
446
+ if (command) {
447
+ const execPolicy = effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY;
448
+ const classification = classifyCommand(command, execPolicy);
449
+ if (classification.decision === "forbidden") {
450
+ return {
451
+ block: true,
452
+ reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
453
+ };
454
+ }
455
+ }
456
+ }
457
+ return {};
458
+ }
459
+ // "ask" → force confirm by overriding the gate decision
460
+ // Falls through to decision.confirm logic below
461
+ }
462
+ }
463
+ catch {
464
+ // Fail-soft: a hook error never blocks or allows; fall through to gate
465
+ }
466
+ }
467
+ let decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
426
468
  if (decision.block)
427
469
  return { block: true, reason: decision.reason };
428
470
  // Prompt band (YAG-510 order): grants → exact-command cache → cap/
@@ -587,6 +629,41 @@ export function registerPermissionGate(pi, deps = {}) {
587
629
  reason: `Guardian needs user approval: ${verdict.rationale} No UI available — the command was held. Find a safer alternative or leave this step for the user.`,
588
630
  };
589
631
  }
632
+ // YAG-506: PermissionRequest hooks fire before the confirm dialog.
633
+ // Only when a UI is present (headless path already failed closed above).
634
+ if (hookRunner && ctx?.hasUI) {
635
+ try {
636
+ const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
637
+ if (hookResult) {
638
+ if (hookResult.decision === "allow") {
639
+ rememberApproved(cwd, command);
640
+ emitGateEvent({
641
+ ...eventBase,
642
+ outcome: "ask_approved",
643
+ riskLevel: verdict.riskLevel,
644
+ rationale: verdict.rationale,
645
+ durationMs,
646
+ consulted: true,
647
+ });
648
+ return {};
649
+ }
650
+ if (hookResult.decision === "deny") {
651
+ emitGateEvent({
652
+ ...eventBase,
653
+ outcome: "ask_denied",
654
+ riskLevel: verdict.riskLevel,
655
+ rationale: verdict.rationale,
656
+ durationMs,
657
+ consulted: true,
658
+ });
659
+ return { block: true, reason: hookResult.reason };
660
+ }
661
+ }
662
+ }
663
+ catch {
664
+ // Fail-soft: hook error → dialog proceeds normally
665
+ }
666
+ }
590
667
  // Offer "don't ask again" only when the grant would actually
591
668
  // cover this command (grant-time validation).
592
669
  const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
@@ -725,7 +802,29 @@ export function registerPermissionGate(pi, deps = {}) {
725
802
  }
726
803
  // review mode with Guardian disabled/capped: fall through to confirm.
727
804
  }
805
+ // YAG-506: PreToolUse "ask" forces confirmation even in auto mode.
806
+ // Uses the cached result from the top of the handler — no re-invocation.
807
+ if (preToolUseResult?.decision === "ask") {
808
+ decision = { block: false, confirm: true };
809
+ }
728
810
  if (decision.confirm) {
811
+ // YAG-506: PermissionRequest hooks run before the confirm dialog.
812
+ if (hookRunner) {
813
+ try {
814
+ const cwd = ctx?.cwd ?? ".";
815
+ const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
816
+ if (hookResult) {
817
+ if (hookResult.decision === "allow")
818
+ return {};
819
+ if (hookResult.decision === "deny") {
820
+ return { block: true, reason: hookResult.reason };
821
+ }
822
+ }
823
+ }
824
+ catch {
825
+ // Fail-soft: hook error → dialog proceeds normally
826
+ }
827
+ }
729
828
  // Review mode needs a confirmation. With no dialog-capable UI (headless),
730
829
  // fail CLOSED: the user explicitly chose a stricter mode, so a write we
731
830
  // cannot get consent for is held rather than silently auto-applied (this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.2-staging.1112.1",
3
+ "version": "0.3.2-staging.1114.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -39,5 +39,5 @@
39
39
  "smol-toml": "^1.8.0",
40
40
  "typebox": "^1.3.11"
41
41
  },
42
- "yagniSourceSha": "792a9033a5199d4129ba1a53605b23881956e4ce"
42
+ "yagniSourceSha": "489e57a1ae86dfa2b4461015e21e1661c128b7aa"
43
43
  }