@pugi/cli 0.1.0-beta.36 → 0.1.0-beta.38

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.
@@ -2,10 +2,19 @@
2
2
  * β1 defaults. Source of truth for the per-command budget envelope.
3
3
  * The runtime is allowed to look these up directly (no need to round
4
4
  * trip through settings.json when no override is in play).
5
+ *
6
+ * 2026-05-28 bump (post-Wave-7 hooks-v2 + 6-perm-modes + auto-classifier
7
+ * added ~12K tokens of system-prompt + tools-schema overhead per turn):
8
+ * `code` 30k → 80k и `fix` 30k → 50k so a single-file refactor on a
9
+ * 1000-line source file no longer exhausts the budget on turn 2.
10
+ * Empirical: smoke `pugi code "сделай snake.html"` on beta.37 burned
11
+ * 36k/30k after 2 tool calls; beta.36 same task closed in 8k. The Wave-7
12
+ * additions are good (Claude Code parity), but the budget cap did not move with
13
+ * them. Claude Code's `code` default is ~80k; matching that restores headroom.
5
14
  */
6
15
  export const beta1DefaultBudgets = {
7
- fix: { maxTokens: 30_000, maxToolCalls: 20 },
8
- code: { maxTokens: 30_000, maxToolCalls: 20 },
16
+ fix: { maxTokens: 50_000, maxToolCalls: 20 },
17
+ code: { maxTokens: 80_000, maxToolCalls: 20 },
9
18
  build: { maxTokens: 200_000, maxToolCalls: 30 },
10
19
  plan: { maxTokens: 200_000, maxToolCalls: 8 },
11
20
  explain: { maxTokens: 20_000, maxToolCalls: 5 },
@@ -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