@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";
|
package/dist/extension/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
42
|
+
"yagniSourceSha": "489e57a1ae86dfa2b4461015e21e1661c128b7aa"
|
|
43
43
|
}
|