@pugi/cli 0.1.0-beta.35 → 0.1.0-beta.37
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/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +144 -53
- package/dist/core/permissions/index.js +3 -1
- package/dist/core/permissions/mode.js +132 -60
- package/dist/core/permissions/state.js +33 -7
- package/dist/core/repl/slash-commands.js +16 -12
- package/dist/core/session.js +48 -0
- package/dist/runtime/cli.js +4 -4
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +11 -9
- package/dist/runtime/commands/plan.js +4 -4
- package/dist/runtime/version.js +1 -1
- package/dist/tui/input-box.js +24 -1
- package/dist/tui/permissions-picker.js +14 -6
- package/dist/tui/repl.js +29 -1
- package/package.json +2 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi hooks v2 — event emitter (Wave 7 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* High-level fire API. Resolves matching hooks for an event, optionally
|
|
5
|
+
* prompts for trust on first encounter, executes each in declaration
|
|
6
|
+
* order (global before project), aggregates results, and surfaces
|
|
7
|
+
* blocking decisions to the caller.
|
|
8
|
+
*
|
|
9
|
+
* Sequential (not parallel) execution is intentional: operators chain
|
|
10
|
+
* `git add` -> `eslint --fix` style hooks that would race otherwise.
|
|
11
|
+
* Parallel execution lands in Phase 2 as an opt-in field on the hook
|
|
12
|
+
* declaration.
|
|
13
|
+
*
|
|
14
|
+
* Brand voice: ASCII only.
|
|
15
|
+
*/
|
|
16
|
+
import { compileMatcher } from './matcher.js';
|
|
17
|
+
import { executeHook } from './executor.js';
|
|
18
|
+
import { ensureHookTrust } from './trust.js';
|
|
19
|
+
import { isToolEventV2 } from './executor.js';
|
|
20
|
+
/**
|
|
21
|
+
* Fire all hooks matching `event`. Returns aggregated outcome — caller
|
|
22
|
+
* inspects `anyBlocked` + `additionalContext` to short-circuit + inject
|
|
23
|
+
* into the next prompt.
|
|
24
|
+
*
|
|
25
|
+
* Untrusted hooks (state === 'denied') are skipped. Hooks pending
|
|
26
|
+
* trust + no prompt available are also skipped (safety default).
|
|
27
|
+
*/
|
|
28
|
+
export async function fireHookEventV2(opts) {
|
|
29
|
+
const { config, event } = opts;
|
|
30
|
+
if (config.isEmpty()) {
|
|
31
|
+
return { event, results: [], anyBlocked: false };
|
|
32
|
+
}
|
|
33
|
+
// Resolve matching hooks. For tool events, match against toolName;
|
|
34
|
+
// for non-tool events, match against ''. The matcher's `*` and
|
|
35
|
+
// alternation grammars compile to predicates.
|
|
36
|
+
const candidate = isToolEventV2(event) ? (opts.toolName ?? '') : '';
|
|
37
|
+
const matching = config.forEvent(event).filter((hook) => {
|
|
38
|
+
try {
|
|
39
|
+
return compileMatcher(hook.matcher)(candidate);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// A malformed matcher should NOT crash the fire. The loader
|
|
43
|
+
// already validates at config-load time; this branch covers
|
|
44
|
+
// hooks injected programmatically by buggy test code.
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
if (matching.length === 0) {
|
|
49
|
+
return { event, results: [], anyBlocked: false };
|
|
50
|
+
}
|
|
51
|
+
const payload = {
|
|
52
|
+
schema_version: 1,
|
|
53
|
+
session_id: opts.sessionId,
|
|
54
|
+
transcript_path: opts.transcriptPath,
|
|
55
|
+
cwd: opts.workspaceRoot,
|
|
56
|
+
hook_event_name: event,
|
|
57
|
+
permission_mode: opts.permissionMode,
|
|
58
|
+
...(opts.toolName !== undefined ? { tool_name: opts.toolName } : {}),
|
|
59
|
+
...(opts.toolInput !== undefined ? { tool_input: opts.toolInput } : {}),
|
|
60
|
+
...(opts.toolResult !== undefined ? { tool_result: opts.toolResult } : {}),
|
|
61
|
+
...(opts.toolError !== undefined ? { tool_error: opts.toolError } : {}),
|
|
62
|
+
...(opts.userPrompt !== undefined ? { user_prompt: opts.userPrompt } : {}),
|
|
63
|
+
agent_id: opts.agentId ?? null,
|
|
64
|
+
agent_type: opts.agentType ?? null,
|
|
65
|
+
};
|
|
66
|
+
const results = [];
|
|
67
|
+
let anyBlocked = false;
|
|
68
|
+
const contextParts = [];
|
|
69
|
+
for (const hook of matching) {
|
|
70
|
+
const command = hook.command ?? '';
|
|
71
|
+
const trustState = await ensureHookTrust({
|
|
72
|
+
workspaceRoot: opts.workspaceRoot,
|
|
73
|
+
command,
|
|
74
|
+
event,
|
|
75
|
+
...(hook.description !== undefined
|
|
76
|
+
? { description: hook.description }
|
|
77
|
+
: {}),
|
|
78
|
+
}, opts.promptFn, opts.trustHomeOverride);
|
|
79
|
+
if (trustState === 'denied') {
|
|
80
|
+
results.push({
|
|
81
|
+
hookCommand: command.slice(0, 200),
|
|
82
|
+
event,
|
|
83
|
+
exitCode: -1,
|
|
84
|
+
stdout: '',
|
|
85
|
+
stderr: 'pugi hooks v2: hook denied by trust ledger',
|
|
86
|
+
decision: { decision: 'allow' },
|
|
87
|
+
elapsedMs: 0,
|
|
88
|
+
timedOut: false,
|
|
89
|
+
blocked: false,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const result = await executeHook({
|
|
94
|
+
hook,
|
|
95
|
+
payload,
|
|
96
|
+
...(opts.env !== undefined ? { env: opts.env } : {}),
|
|
97
|
+
});
|
|
98
|
+
if (result.blocked) {
|
|
99
|
+
anyBlocked = true;
|
|
100
|
+
}
|
|
101
|
+
if (result.additionalContext) {
|
|
102
|
+
contextParts.push(result.additionalContext);
|
|
103
|
+
}
|
|
104
|
+
results.push(result);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
event,
|
|
108
|
+
results,
|
|
109
|
+
anyBlocked,
|
|
110
|
+
...(contextParts.length > 0
|
|
111
|
+
? { additionalContext: contextParts.join('\n\n') }
|
|
112
|
+
: {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=event-emitter.js.map
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi hooks v2 — executor (Wave 7 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Spawns the hook command with the JSON payload on stdin, captures
|
|
5
|
+
* stdout / stderr, applies the timeout watchdog, and parses the
|
|
6
|
+
* decision protocol:
|
|
7
|
+
*
|
|
8
|
+
* - Exit 0 + empty stdout -> { decision: 'allow' }
|
|
9
|
+
* - Exit 0 + JSON `{"decision":"deny"}` -> blocked
|
|
10
|
+
* - Exit 0 + JSON `{"decision":"ask"}` -> ask operator (TUI) /
|
|
11
|
+
* refuse in headless
|
|
12
|
+
* - Exit 0 + JSON `{"additionalContext"}` -> inject into next prompt
|
|
13
|
+
* - Exit 1 -> log warning, continue
|
|
14
|
+
* - Exit 2 -> block; stderr = reason
|
|
15
|
+
* (Claude Code compat)
|
|
16
|
+
*
|
|
17
|
+
* Safety properties:
|
|
18
|
+
* - 1 MiB output cap per stream (configurable). Misbehaving hooks
|
|
19
|
+
* that emit unbounded output are killed.
|
|
20
|
+
* - SIGTERM then SIGKILL with a 2 s grace window.
|
|
21
|
+
* - Spawn failures never crash the parent.
|
|
22
|
+
*
|
|
23
|
+
* Brand voice: ASCII only.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { HOOK_OUTPUT_CAP_BYTES_V2, } from './types.js';
|
|
27
|
+
const SIGKILL_GRACE_MS = 2_000;
|
|
28
|
+
/**
|
|
29
|
+
* Execute a single hook + return a typed result. Never throws — every
|
|
30
|
+
* failure mode (spawn error, timeout, stream cap) lands in a
|
|
31
|
+
* structured result so the caller can log + continue.
|
|
32
|
+
*/
|
|
33
|
+
export async function executeHook(opts) {
|
|
34
|
+
const { hook, payload, env, outputCapBytes = HOOK_OUTPUT_CAP_BYTES_V2 } = opts;
|
|
35
|
+
if (hook.type !== 'command') {
|
|
36
|
+
// Phase 1 only supports command-type hooks; other types resolve to
|
|
37
|
+
// an allow-decision so the rest of the pipeline keeps moving.
|
|
38
|
+
return makeUnsupportedTypeResult(hook);
|
|
39
|
+
}
|
|
40
|
+
const command = hook.command ?? '';
|
|
41
|
+
if (command.trim() === '') {
|
|
42
|
+
return {
|
|
43
|
+
hookCommand: '',
|
|
44
|
+
event: hook.event,
|
|
45
|
+
exitCode: -1,
|
|
46
|
+
stdout: '',
|
|
47
|
+
stderr: 'pugi hooks v2: command hook has empty command',
|
|
48
|
+
decision: { decision: 'allow' },
|
|
49
|
+
elapsedMs: 0,
|
|
50
|
+
timedOut: false,
|
|
51
|
+
blocked: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const startedAt = Date.now();
|
|
55
|
+
const timeoutMs = hook.timeoutMs ?? 5_000;
|
|
56
|
+
const stdinJson = JSON.stringify(payload);
|
|
57
|
+
const childEnv = {
|
|
58
|
+
...(env ?? process.env),
|
|
59
|
+
PUGI_HOOK_PAYLOAD: stdinJson,
|
|
60
|
+
PUGI_HOOK_EVENT: payload.hook_event_name,
|
|
61
|
+
PUGI_HOOK_SESSION_ID: payload.session_id,
|
|
62
|
+
PUGI_HOOK_CWD: payload.cwd,
|
|
63
|
+
PUGI_HOOK_TRANSCRIPT_PATH: payload.transcript_path,
|
|
64
|
+
PUGI_HOOK_PERMISSION_MODE: payload.permission_mode,
|
|
65
|
+
PUGI_HOOK_TOOL_NAME: payload.tool_name ?? '',
|
|
66
|
+
PUGI_HOOK_AGENT_ID: payload.agent_id ?? '',
|
|
67
|
+
PUGI_HOOK_AGENT_TYPE: payload.agent_type ?? '',
|
|
68
|
+
};
|
|
69
|
+
return new Promise((resolvePromise) => {
|
|
70
|
+
const child = spawn('/bin/sh', ['-c', command], {
|
|
71
|
+
env: childEnv,
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
});
|
|
74
|
+
let stdout = '';
|
|
75
|
+
let stderr = '';
|
|
76
|
+
let timedOut = false;
|
|
77
|
+
let killedForCap = false;
|
|
78
|
+
let sigKillTimer;
|
|
79
|
+
const escalateKill = () => {
|
|
80
|
+
if (sigKillTimer)
|
|
81
|
+
return;
|
|
82
|
+
sigKillTimer = setTimeout(() => {
|
|
83
|
+
if (!child.killed)
|
|
84
|
+
child.kill('SIGKILL');
|
|
85
|
+
}, SIGKILL_GRACE_MS);
|
|
86
|
+
if (sigKillTimer.unref)
|
|
87
|
+
sigKillTimer.unref();
|
|
88
|
+
};
|
|
89
|
+
const enforceCap = () => {
|
|
90
|
+
if (killedForCap)
|
|
91
|
+
return;
|
|
92
|
+
if (stdout.length + stderr.length <= outputCapBytes)
|
|
93
|
+
return;
|
|
94
|
+
killedForCap = true;
|
|
95
|
+
child.kill('SIGTERM');
|
|
96
|
+
escalateKill();
|
|
97
|
+
};
|
|
98
|
+
child.stdout?.on('data', (chunk) => {
|
|
99
|
+
if (killedForCap)
|
|
100
|
+
return;
|
|
101
|
+
stdout += chunk.toString('utf8');
|
|
102
|
+
enforceCap();
|
|
103
|
+
});
|
|
104
|
+
child.stderr?.on('data', (chunk) => {
|
|
105
|
+
if (killedForCap)
|
|
106
|
+
return;
|
|
107
|
+
stderr += chunk.toString('utf8');
|
|
108
|
+
enforceCap();
|
|
109
|
+
});
|
|
110
|
+
if (child.stdin) {
|
|
111
|
+
child.stdin.on('error', () => {
|
|
112
|
+
// EPIPE on a hook that ignores stdin is benign — payload also
|
|
113
|
+
// arrives via env var PUGI_HOOK_PAYLOAD.
|
|
114
|
+
});
|
|
115
|
+
child.stdin.end(stdinJson);
|
|
116
|
+
}
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
timedOut = true;
|
|
119
|
+
child.kill('SIGTERM');
|
|
120
|
+
escalateKill();
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
if (timer.unref)
|
|
123
|
+
timer.unref();
|
|
124
|
+
child.on('error', (error) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
if (sigKillTimer)
|
|
127
|
+
clearTimeout(sigKillTimer);
|
|
128
|
+
resolvePromise({
|
|
129
|
+
hookCommand: truncate(command, 200),
|
|
130
|
+
event: hook.event,
|
|
131
|
+
exitCode: -1,
|
|
132
|
+
stdout,
|
|
133
|
+
stderr: stderr || error.message,
|
|
134
|
+
decision: { decision: 'allow' },
|
|
135
|
+
elapsedMs: Date.now() - startedAt,
|
|
136
|
+
timedOut: false,
|
|
137
|
+
blocked: false,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
child.on('close', (code, signal) => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
if (sigKillTimer)
|
|
143
|
+
clearTimeout(sigKillTimer);
|
|
144
|
+
const exitCode = resolveExitCode(code, signal);
|
|
145
|
+
const decision = parseDecision(exitCode, stdout, stderr);
|
|
146
|
+
const blocked = isBlocked(exitCode, decision);
|
|
147
|
+
const blockReason = blocked ? blockReasonOf(exitCode, decision, stderr) : undefined;
|
|
148
|
+
const additionalContext = readAdditionalContext(decision);
|
|
149
|
+
resolvePromise({
|
|
150
|
+
hookCommand: truncate(command, 200),
|
|
151
|
+
event: hook.event,
|
|
152
|
+
exitCode,
|
|
153
|
+
stdout,
|
|
154
|
+
stderr,
|
|
155
|
+
decision,
|
|
156
|
+
elapsedMs: Date.now() - startedAt,
|
|
157
|
+
timedOut,
|
|
158
|
+
blocked,
|
|
159
|
+
...(blockReason !== undefined ? { blockReason } : {}),
|
|
160
|
+
...(additionalContext !== undefined ? { additionalContext } : {}),
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function makeUnsupportedTypeResult(hook) {
|
|
166
|
+
return {
|
|
167
|
+
hookCommand: `<${hook.type}>`,
|
|
168
|
+
event: hook.event,
|
|
169
|
+
exitCode: 0,
|
|
170
|
+
stdout: '',
|
|
171
|
+
stderr: `pugi hooks v2: type '${hook.type}' is not supported in Phase 1`,
|
|
172
|
+
decision: { decision: 'allow' },
|
|
173
|
+
elapsedMs: 0,
|
|
174
|
+
timedOut: false,
|
|
175
|
+
blocked: false,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function resolveExitCode(code, signal) {
|
|
179
|
+
if (code !== null)
|
|
180
|
+
return code;
|
|
181
|
+
if (signal === 'SIGTERM')
|
|
182
|
+
return -15;
|
|
183
|
+
if (signal === 'SIGKILL')
|
|
184
|
+
return -9;
|
|
185
|
+
return -1;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse the JSON decision protocol. Falls back to `{ decision: 'allow' }`
|
|
189
|
+
* for any shape we do not recognize so a sloppy hook script does not
|
|
190
|
+
* silently start blocking tool dispatch.
|
|
191
|
+
*
|
|
192
|
+
* Exit-2 is the CC-compat shortcut: stderr is the block reason.
|
|
193
|
+
*/
|
|
194
|
+
export function parseDecision(exitCode, stdout, stderr) {
|
|
195
|
+
if (exitCode === 2) {
|
|
196
|
+
const reason = stderr.trim() || 'hook exited 2';
|
|
197
|
+
return { decision: 'deny', reason };
|
|
198
|
+
}
|
|
199
|
+
const trimmed = stdout.trim();
|
|
200
|
+
if (trimmed === '') {
|
|
201
|
+
return { decision: 'allow' };
|
|
202
|
+
}
|
|
203
|
+
// Try JSON first, but only when the stdout looks like a JSON object —
|
|
204
|
+
// a hook that prints `hello world` to stdout is not a decision.
|
|
205
|
+
if (!trimmed.startsWith('{')) {
|
|
206
|
+
return { decision: 'allow' };
|
|
207
|
+
}
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = JSON.parse(trimmed);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return { decision: 'allow' };
|
|
214
|
+
}
|
|
215
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
216
|
+
return { decision: 'allow' };
|
|
217
|
+
}
|
|
218
|
+
const raw = parsed;
|
|
219
|
+
const decision = typeof raw.decision === 'string' ? raw.decision : undefined;
|
|
220
|
+
if (decision === 'deny') {
|
|
221
|
+
const reason = typeof raw.reason === 'string' && raw.reason.length > 0
|
|
222
|
+
? raw.reason
|
|
223
|
+
: 'hook returned decision=deny';
|
|
224
|
+
return { decision: 'deny', reason };
|
|
225
|
+
}
|
|
226
|
+
if (decision === 'ask') {
|
|
227
|
+
const prompt = typeof raw.prompt === 'string' && raw.prompt.length > 0
|
|
228
|
+
? raw.prompt
|
|
229
|
+
: 'hook requested operator approval';
|
|
230
|
+
return { decision: 'ask', prompt };
|
|
231
|
+
}
|
|
232
|
+
if (decision === 'allow' && typeof raw.additionalContext === 'string') {
|
|
233
|
+
return {
|
|
234
|
+
decision: 'allow',
|
|
235
|
+
additionalContext: raw.additionalContext,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (decision === 'allow') {
|
|
239
|
+
return { decision: 'allow' };
|
|
240
|
+
}
|
|
241
|
+
if (typeof raw.additionalContext === 'string') {
|
|
242
|
+
return { additionalContext: raw.additionalContext };
|
|
243
|
+
}
|
|
244
|
+
return { decision: 'allow' };
|
|
245
|
+
}
|
|
246
|
+
function isBlocked(exitCode, decision) {
|
|
247
|
+
if (exitCode === 2)
|
|
248
|
+
return true;
|
|
249
|
+
if ('decision' in decision && decision.decision === 'deny')
|
|
250
|
+
return true;
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
function blockReasonOf(exitCode, decision, stderr) {
|
|
254
|
+
if (exitCode === 2) {
|
|
255
|
+
return stderr.trim() || 'hook exited 2';
|
|
256
|
+
}
|
|
257
|
+
if ('decision' in decision && decision.decision === 'deny') {
|
|
258
|
+
return decision.reason;
|
|
259
|
+
}
|
|
260
|
+
return 'hook blocked';
|
|
261
|
+
}
|
|
262
|
+
function readAdditionalContext(decision) {
|
|
263
|
+
if (!('decision' in decision)) {
|
|
264
|
+
return decision.additionalContext;
|
|
265
|
+
}
|
|
266
|
+
if (decision.decision === 'allow' && 'additionalContext' in decision) {
|
|
267
|
+
return decision.additionalContext;
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
function truncate(value, max) {
|
|
272
|
+
if (value.length <= max)
|
|
273
|
+
return value;
|
|
274
|
+
return `${value.slice(0, max - 3)}...`;
|
|
275
|
+
}
|
|
276
|
+
/** Convenience predicate exported for the event-emitter. */
|
|
277
|
+
export function isToolEventV2(event) {
|
|
278
|
+
return (event === 'PreToolUse' ||
|
|
279
|
+
event === 'PostToolUse' ||
|
|
280
|
+
event === 'PostToolUseFailure');
|
|
281
|
+
}
|
|
282
|
+
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi hooks v2 — public surface (Wave 7 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* The v2 surface is a Claude Code parity layer that lives alongside
|
|
5
|
+
* the existing `core/hooks/` MVP module. See `types.ts` for the
|
|
6
|
+
* design note explaining why both surfaces co-exist.
|
|
7
|
+
*
|
|
8
|
+
* Stable imports:
|
|
9
|
+
*
|
|
10
|
+
* import {
|
|
11
|
+
* loadHooksConfigV2,
|
|
12
|
+
* fireHookEventV2,
|
|
13
|
+
* type HookEventV2,
|
|
14
|
+
* } from '../core/hooks/v2/index.js';
|
|
15
|
+
*
|
|
16
|
+
* Brand voice: ASCII only.
|
|
17
|
+
*/
|
|
18
|
+
export { ALL_HOOK_EVENTS_V2, DEFAULT_HOOK_TIMEOUT_MS_V2, HOOK_OUTPUT_CAP_BYTES_V2, MAX_HOOK_TIMEOUT_MS_V2, PHASE_1_HOOK_EVENTS, } from './types.js';
|
|
19
|
+
export { HooksConfigV2, SUPPORTED_HOOK_TYPES_V2, defaultGlobalHooksPath, defaultProjectHooksPath, loadHooksConfigV2, } from './loader.js';
|
|
20
|
+
export { compileMatcher, matches } from './matcher.js';
|
|
21
|
+
export { executeHook, isToolEventV2, parseDecision } from './executor.js';
|
|
22
|
+
export { ensureHookTrust, getHookTrust, listHookTrust, setHookTrust, trustKey, trustLedgerPath, } from './trust.js';
|
|
23
|
+
export { fireHookEventV2 } from './event-emitter.js';
|
|
24
|
+
export { firePostCompact, firePostToolUse, firePostToolUseFailure, firePreCompact, firePreToolUse, fireSessionEnd, fireSessionStart, fireStop, fireUserPromptSubmit, } from './lifecycle.js';
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi hooks v2 - lifecycle integration helpers (Wave 7 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* High-level fire-and-forget wrappers that hide the loader + emitter
|
|
5
|
+
* plumbing from the callers. Each function:
|
|
6
|
+
*
|
|
7
|
+
* 1. Loads the merged hooks config for the workspace.
|
|
8
|
+
* 2. Returns early when the config is empty (zero-cost when no hooks).
|
|
9
|
+
* 3. Resolves the trust state (skipping denied hooks).
|
|
10
|
+
* 4. Fires every matching hook + returns the aggregated outcome.
|
|
11
|
+
*
|
|
12
|
+
* The lifecycle helpers are called from:
|
|
13
|
+
* - `core/repl/session.ts` (SessionStart, SessionEnd, UserPromptSubmit,
|
|
14
|
+
* Stop)
|
|
15
|
+
* - `core/engine/tool-bridge.ts` (PreToolUse, PostToolUse,
|
|
16
|
+
* PostToolUseFailure)
|
|
17
|
+
* - `runtime/commands/compact.ts` (PreCompact, PostCompact)
|
|
18
|
+
*
|
|
19
|
+
* Best-effort: all helpers swallow exceptions so a misconfigured hook
|
|
20
|
+
* NEVER crashes the session boot path. Errors surface via
|
|
21
|
+
* `pugi hooks doctor` (Phase 2) and the per-session events log.
|
|
22
|
+
*
|
|
23
|
+
* Brand voice: ASCII only.
|
|
24
|
+
*/
|
|
25
|
+
import { fireHookEventV2 } from './event-emitter.js';
|
|
26
|
+
import { loadHooksConfigV2 } from './loader.js';
|
|
27
|
+
/** Empty no-op outcome. */
|
|
28
|
+
function noopOutcome(event) {
|
|
29
|
+
return { event, results: [], anyBlocked: false };
|
|
30
|
+
}
|
|
31
|
+
/** Internal helper that loads + fires + swallows errors. */
|
|
32
|
+
async function fireSafe(ctx, event, extra = {}) {
|
|
33
|
+
try {
|
|
34
|
+
const config = loadHooksConfigV2({
|
|
35
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
36
|
+
});
|
|
37
|
+
if (config.isEmpty()) {
|
|
38
|
+
return noopOutcome(event);
|
|
39
|
+
}
|
|
40
|
+
return await fireHookEventV2({
|
|
41
|
+
config,
|
|
42
|
+
event,
|
|
43
|
+
sessionId: ctx.sessionId,
|
|
44
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
45
|
+
transcriptPath: ctx.transcriptPath,
|
|
46
|
+
permissionMode: ctx.permissionMode,
|
|
47
|
+
...(ctx.promptFn !== undefined ? { promptFn: ctx.promptFn } : {}),
|
|
48
|
+
...extra,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Lifecycle helpers MUST NOT crash the session. A malformed config
|
|
53
|
+
// surfaces via `pugi hooks doctor`.
|
|
54
|
+
return noopOutcome(event);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Fire `SessionStart` hooks. Never throws. */
|
|
58
|
+
export function fireSessionStart(ctx) {
|
|
59
|
+
return fireSafe(ctx, 'SessionStart');
|
|
60
|
+
}
|
|
61
|
+
/** Fire `SessionEnd` hooks. Never throws. */
|
|
62
|
+
export function fireSessionEnd(ctx) {
|
|
63
|
+
return fireSafe(ctx, 'SessionEnd');
|
|
64
|
+
}
|
|
65
|
+
/** Fire `Stop` hooks. Never throws. */
|
|
66
|
+
export function fireStop(ctx) {
|
|
67
|
+
return fireSafe(ctx, 'Stop');
|
|
68
|
+
}
|
|
69
|
+
/** Fire `UserPromptSubmit` hooks. Never throws. */
|
|
70
|
+
export function fireUserPromptSubmit(ctx, prompt) {
|
|
71
|
+
return fireSafe(ctx, 'UserPromptSubmit', { userPrompt: prompt });
|
|
72
|
+
}
|
|
73
|
+
/** Fire `PreToolUse` hooks. Never throws. Caller checks `anyBlocked`. */
|
|
74
|
+
export function firePreToolUse(ctx) {
|
|
75
|
+
return fireSafe(ctx, 'PreToolUse', {
|
|
76
|
+
toolName: ctx.toolName,
|
|
77
|
+
toolInput: ctx.toolInput,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Fire `PostToolUse` hooks. Never throws. */
|
|
81
|
+
export function firePostToolUse(ctx, toolResult) {
|
|
82
|
+
return fireSafe(ctx, 'PostToolUse', {
|
|
83
|
+
toolName: ctx.toolName,
|
|
84
|
+
toolInput: ctx.toolInput,
|
|
85
|
+
toolResult,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Fire `PostToolUseFailure` hooks. Never throws. */
|
|
89
|
+
export function firePostToolUseFailure(ctx, toolError) {
|
|
90
|
+
return fireSafe(ctx, 'PostToolUseFailure', {
|
|
91
|
+
toolName: ctx.toolName,
|
|
92
|
+
toolInput: ctx.toolInput,
|
|
93
|
+
toolError,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Fire `PreCompact` hooks. Never throws. */
|
|
97
|
+
export function firePreCompact(ctx) {
|
|
98
|
+
return fireSafe(ctx, 'PreCompact');
|
|
99
|
+
}
|
|
100
|
+
/** Fire `PostCompact` hooks. Never throws. */
|
|
101
|
+
export function firePostCompact(ctx) {
|
|
102
|
+
return fireSafe(ctx, 'PostCompact');
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=lifecycle.js.map
|