@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.
- package/dist/cli.js +13 -0
- package/dist/crashReport.d.ts +12 -0
- package/dist/crashReport.js +28 -1
- package/dist/extension/crashReport.d.ts +18 -0
- package/dist/extension/crashReport.js +35 -2
- package/dist/extension/footer.d.ts +1 -1
- package/dist/extension/hooks.d.ts +111 -0
- package/dist/extension/hooks.js +666 -0
- package/dist/extension/index.d.ts +13 -6
- package/dist/extension/index.js +57 -7
- package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
- package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
- package/dist/extension/permission/dbReadPolicy.js +227 -0
- package/dist/extension/{execPolicy.js → permission/execPolicy.js} +99 -8
- package/dist/extension/{permission.d.ts → permission/gate.d.ts} +10 -3
- package/dist/extension/{permission.js → permission/gate.js} +156 -9
- package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
- package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
- package/dist/extension/permission/index.d.ts +14 -0
- package/dist/extension/permission/index.js +14 -0
- package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
- package/dist/extension/permission/packageManagerPolicy.js +170 -0
- package/dist/extension/pipeline/activityFeed.js +19 -5
- package/dist/extension/pipeline/checker.d.ts +99 -0
- package/dist/extension/pipeline/checker.js +238 -0
- package/dist/extension/pipeline/fanout.d.ts +116 -0
- package/dist/extension/pipeline/fanout.js +248 -0
- package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
- package/dist/extension/pipeline/fanoutBeats.js +86 -0
- package/dist/extension/pipeline/goCommand.d.ts +14 -0
- package/dist/extension/pipeline/goCommand.js +38 -1
- package/dist/extension/pipeline/headlessGo.d.ts +163 -0
- package/dist/extension/pipeline/headlessGo.js +333 -0
- package/dist/extension/pipeline/invocation.d.ts +31 -3
- package/dist/extension/pipeline/invocation.js +37 -3
- package/dist/extension/pipeline/mission.d.ts +55 -0
- package/dist/extension/pipeline/mission.js +70 -0
- package/dist/extension/pipeline/orchestrator.d.ts +48 -3
- package/dist/extension/pipeline/orchestrator.js +450 -9
- package/dist/extension/pipeline/personas.d.ts +16 -1
- package/dist/extension/pipeline/personas.js +118 -7
- package/dist/extension/pipeline/runSession.d.ts +45 -1
- package/dist/extension/pipeline/runState.d.ts +57 -12
- package/dist/extension/pipeline/runState.js +60 -18
- package/dist/extension/pipeline/runner.js +10 -1
- package/dist/extension/pipeline/stages.d.ts +84 -7
- package/dist/extension/pipeline/stages.js +166 -0
- package/dist/extension/pipeline/tierCap.d.ts +32 -0
- package/dist/extension/pipeline/tierCap.js +57 -0
- package/dist/extension/pipeline/types.d.ts +130 -1
- package/dist/extension/pipeline/types.js +17 -0
- package/dist/extension/pipeline/verify.d.ts +86 -3
- package/dist/extension/pipeline/verify.js +175 -6
- package/dist/extension/subagents.js +13 -0
- package/dist/extension/turnLog.d.ts +38 -0
- package/dist/extension/turnLog.js +93 -0
- package/dist/goHeadless.d.ts +75 -0
- package/dist/goHeadless.js +132 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +12 -0
- package/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +1 -1
- package/package.json +2 -2
- /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
- /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
|