@pugi/cli 0.1.0-beta.24 → 0.1.0-beta.25

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.
@@ -260,6 +260,47 @@ export class NativePugiEngineAdapter {
260
260
  ambientContextBlock = '';
261
261
  }
262
262
  }
263
+ // Leak L28 (2026-05-27): AST-light repo-map injection. We build a
264
+ // compact `## Repo map` block (capped at the formatter's default
265
+ // 8 KB ≈ 2K tokens) from the workspace source tree + splice it
266
+ // onto the system prompt alongside the ambient PUGI.md block.
267
+ // `--bare` skips this exactly like the PUGI.md walk — the engine
268
+ // sees nothing the operator did not explicitly hand it. The build
269
+ // is deferred к `setImmediate` semantics by being a sync call
270
+ // AFTER the boot probes; the cost is one stat per source file
271
+ // (the cache catches mtime-unchanged files и skips re-extraction).
272
+ // Failures are swallowed: repo-map is enrichment, never a gate.
273
+ let repoMapBlock = '';
274
+ if (!isBareMode()) {
275
+ try {
276
+ const { buildAndFormatRepoMap } = await import('../repo-map/build.js');
277
+ const verdict = buildAndFormatRepoMap({
278
+ root,
279
+ // Boot path is best-effort: never refresh during engine boot
280
+ // (the operator can `pugi repo-map --refresh` manually). The
281
+ // cache freshness check catches every realistic edit pattern
282
+ // and avoids walking the tree on every engine invocation.
283
+ refresh: false,
284
+ // Persist the cache so the next boot reuses extracts. Engine
285
+ // boot runs on every command, so missing the persist would
286
+ // hot-loop the extractor on each invocation.
287
+ writeCache: true,
288
+ // Omit the formatter's section header — the system prompt
289
+ // already structures the ambient blocks, и a second `##`
290
+ // would fragment the prompt cache на a model-by-model basis.
291
+ omitHeader: false,
292
+ });
293
+ if (verdict.build.ok && verdict.format && verdict.format.bytes > 0) {
294
+ repoMapBlock = verdict.format.text;
295
+ }
296
+ }
297
+ catch {
298
+ // Any failure in the repo-map pipeline drops the block. The
299
+ // engine continues without enrichment — the failure mode is
300
+ // identical to the cold-boot path before L28 landed.
301
+ repoMapBlock = '';
302
+ }
303
+ }
263
304
  let traverseResult;
264
305
  // Leak L22 (2026-05-27): `--bare` skips the parent-dir PUGI.md /
265
306
  // AGENTS.md / CLAUDE.md / GEMINI.md walk-up. The engine sees only
@@ -579,9 +620,17 @@ export class NativePugiEngineAdapter {
579
620
  // nothing OR bare mode is on, `ambientContextBlock === ''`
580
621
  // and the system prompt is unchanged — no leading blank
581
622
  // line, no empty wrapper tag.
582
- systemPrompt: ambientContextBlock
583
- ? `${ambientContextBlock}\n\n${systemPromptFor(kind)}`
584
- : systemPromptFor(kind),
623
+ //
624
+ // Leak L28 (2026-05-27): the `repoMapBlock` is splice'd
625
+ // between the ambient PUGI.md and the persona prompt so
626
+ // the model sees the workspace structure WITH the operator's
627
+ // ambient guidance fronting it. Empty blocks drop cleanly:
628
+ // `composeSystemPrompt` filters falsy entries before joining.
629
+ systemPrompt: composeSystemPrompt([
630
+ ambientContextBlock,
631
+ repoMapBlock,
632
+ systemPromptFor(kind),
633
+ ]),
585
634
  // β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
586
635
  // applied above. Falls back to verbatim `task.prompt` when
587
636
  // both the prefix block is empty AND the intent classifier
@@ -949,4 +998,19 @@ function relativeOrAbsolute(workspaceRoot, cwd) {
949
998
  const rel = absCwd.startsWith(absRoot + '/') ? absCwd.slice(absRoot.length + 1) : null;
950
999
  return rel ?? absCwd;
951
1000
  }
1001
+ /**
1002
+ * Leak L28 helper — splice multiple ambient blocks onto a persona
1003
+ * system prompt, dropping empty entries cleanly. The join character
1004
+ * is `\n\n` so each block renders as a discrete paragraph the model
1005
+ * can attend к without bleeding into its neighbour.
1006
+ *
1007
+ * Empty blocks return the base prompt unchanged — no leading
1008
+ * separators, no trailing whitespace. Mirrors the original
1009
+ * `ambientContextBlock ? ... : ...` shape so the single-block path
1010
+ * before L28 stays byte-identical (prompt cache friendliness).
1011
+ */
1012
+ export function composeSystemPrompt(blocks) {
1013
+ const nonEmpty = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
1014
+ return nonEmpty.join('\n\n');
1015
+ }
952
1016
  //# sourceMappingURL=native-pugi.js.map
@@ -14,6 +14,7 @@ import { buildDenialContext, DENIAL_REMINDER_THRESHOLD, } from '../denial-tracki
14
14
  import { stripInternalFields } from './strip-internal-fields.js';
15
15
  import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
16
16
  import { RetryBudget, RetryBudgetExhausted, hashArgs } from '../retry-budget/index.js';
17
+ import { runPostEditDiagnostics, } from '../lsp/post-edit-diagnostics.js';
17
18
  /**
18
19
  * Tool-bridge: turns the abstract tool registry into:
19
20
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -516,7 +517,7 @@ function requireString(obj, key) {
516
517
  throw new Error(`tool argument "${key}" must be a string`);
517
518
  }
518
519
  export function buildExecutor(input) {
519
- const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
520
+ const { kind, ctx, hooks, mvpHooksConfig, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
520
521
  // Leak L31: per-cycle budget. Default to a fresh instance scoped to
521
522
  // this executor's closure lifetime; tests pass their own.
522
523
  const retryBudget = input.retryBudget ?? new RetryBudget();
@@ -687,6 +688,33 @@ export function buildExecutor(input) {
687
688
  }
688
689
  }
689
690
  }
691
+ // Leak L12 MVP: fire `hooks-mvp.json` PreToolUse hooks. Distinct
692
+ // config file from the legacy `hooks.json` system so operator
693
+ // configs do not collide. Same blocking semantics — a non-zero
694
+ // exit from a hook declared `blocking: true` refuses the dispatch
695
+ // with `HOOK_BLOCKED:` sentinel. Bypass mode skips this surface
696
+ // identically to the legacy hooks block above.
697
+ if (mvpHooksConfig && sessionId && !hooksBypassed && !mvpHooksConfig.isEmpty()) {
698
+ const { fireHooks } = await import('../hooks/index.js');
699
+ const outcome = await fireHooks({
700
+ config: mvpHooksConfig,
701
+ event: 'PreToolUse',
702
+ payload: {
703
+ event: 'PreToolUse',
704
+ sessionId,
705
+ toolName: name,
706
+ toolInputSummary: hashArgs(argsRaw),
707
+ },
708
+ toolName: name,
709
+ workspaceRoot: ctx.root,
710
+ });
711
+ if (outcome.anyBlocked) {
712
+ const blocking = outcome.results.find((r) => r.blocked);
713
+ const sentinel = blocking?.blockSentinel ??
714
+ `HOOK_BLOCKED: PreToolUse MVP-hook refused ${name}`;
715
+ throw recordDenial(name, argsForTracking, sentinel);
716
+ }
717
+ }
690
718
  // β4 M1/M3: MCP dispatch deferred to the `dispatch` closure below so
691
719
  // PostToolUse / PostToolUseFailure hooks observe MCP calls just like
692
720
  // native calls. The dispatcher does its own argument parsing — MCP
@@ -756,6 +784,14 @@ export function buildExecutor(input) {
756
784
  };
757
785
  try {
758
786
  const result = await dispatch();
787
+ // Leak L15 (2026-05-27): post-edit LSP diagnostics. After a
788
+ // successful `edit` / `write` / `multi_edit`, ask the cached
789
+ // language server for diagnostics on the touched file(s) and
790
+ // append the result to the tool envelope so the model can
791
+ // self-correct in the same turn. Silent skip when the language
792
+ // is unsupported, no server is installed, or the request times
793
+ // out — agent throughput beats diagnostic recall.
794
+ const augmented = await appendPostEditDiagnostics(name, args, ctx, result);
759
795
  if (hooks && sessionId && !hooksBypassed) {
760
796
  const path = extractToolPath(name, argsRaw);
761
797
  await hooks.fire({
@@ -763,10 +799,10 @@ export function buildExecutor(input) {
763
799
  event: 'PostToolUse',
764
800
  tool: name,
765
801
  path,
766
- payload: { tool: name, arguments: argsRaw, ok: true, result: result.slice(0, 1024) },
802
+ payload: { tool: name, arguments: argsRaw, ok: true, result: augmented.slice(0, 1024) },
767
803
  });
768
804
  }
769
- return result;
805
+ return augmented;
770
806
  }
771
807
  catch (error) {
772
808
  // Leak L6 — surface the PermissionDenied sentinel as a model-
@@ -1161,4 +1197,88 @@ function dispatchMultiEdit(args, ctx) {
1161
1197
  const result = multiEdit(ctx, edits);
1162
1198
  return JSON.stringify(result);
1163
1199
  }
1200
+ /* ---------------------------- Leak L15 hook ---------------------------- */
1201
+ /**
1202
+ * Tool names that mutate workspace files. After a successful dispatch
1203
+ * of any of these, the L15 post-edit diagnostics hook fires. The set
1204
+ * is intentionally tight — `task_*` / `todo_write` write to ledger
1205
+ * files (not workspace source) so they stay out, and `bash` is too
1206
+ * coarse (a `bash` call can write any path, and we'd need to parse
1207
+ * the command to know which — out of scope for L15).
1208
+ */
1209
+ const POST_EDIT_TOOLS = new Set(['edit', 'write', 'multi_edit']);
1210
+ /**
1211
+ * Append LSP diagnostics to the tool envelope after a successful
1212
+ * edit / write / multi_edit. Silent skip is the default — missing
1213
+ * binary, unsupported language, request timeout, and "no diagnostics"
1214
+ * all leave the envelope unchanged.
1215
+ *
1216
+ * Opt-in via `.pugi/settings.json::lsp.postEditDiagnostics = true`
1217
+ * OR `PUGI_LSP_POST_EDIT=1`. Off by default until dogfood validates
1218
+ * the cold-start cost vs the model-loop benefit (Leak L15).
1219
+ */
1220
+ async function appendPostEditDiagnostics(name, args, ctx, result) {
1221
+ if (!POST_EDIT_TOOLS.has(name))
1222
+ return result;
1223
+ if (!isPostEditEnabled(ctx))
1224
+ return result;
1225
+ const paths = extractEditedPaths(name, args);
1226
+ if (paths.length === 0)
1227
+ return result;
1228
+ const tails = [];
1229
+ for (const filePath of paths) {
1230
+ const opts = {
1231
+ cwd: ctx.root,
1232
+ ...(ctx.settings.lsp ? { lspSettings: ctx.settings.lsp } : {}),
1233
+ };
1234
+ try {
1235
+ const diag = await runPostEditDiagnostics(filePath, opts);
1236
+ if (!diag.skip) {
1237
+ tails.push(diag.tail);
1238
+ }
1239
+ }
1240
+ catch {
1241
+ // Belt-and-suspenders: any unexpected throw from the hook is
1242
+ // swallowed. The model never blocks on LSP.
1243
+ }
1244
+ }
1245
+ if (tails.length === 0)
1246
+ return result;
1247
+ return `${result}\n${tails.join('\n')}`;
1248
+ }
1249
+ function isPostEditEnabled(ctx) {
1250
+ const envFlag = process.env.PUGI_LSP_POST_EDIT;
1251
+ if (envFlag === '1' || envFlag === 'true')
1252
+ return true;
1253
+ if (envFlag === '0' || envFlag === 'false')
1254
+ return false;
1255
+ return ctx.settings.lsp?.postEditDiagnostics === true;
1256
+ }
1257
+ /**
1258
+ * Pull the workspace-relative file path(s) the tool just touched.
1259
+ * Each branch mirrors the args shape its `dispatch*` handler reads;
1260
+ * a deformed args object yields an empty list so the hook silently
1261
+ * skips instead of throwing inside the augmentation layer.
1262
+ */
1263
+ function extractEditedPaths(name, args) {
1264
+ if (name === 'edit' || name === 'write') {
1265
+ const path = args['path'];
1266
+ return typeof path === 'string' && path.length > 0 ? [path] : [];
1267
+ }
1268
+ if (name === 'multi_edit') {
1269
+ const edits = args['edits'];
1270
+ if (!Array.isArray(edits))
1271
+ return [];
1272
+ const seen = new Set();
1273
+ for (const entry of edits) {
1274
+ if (!entry || typeof entry !== 'object')
1275
+ continue;
1276
+ const file = entry['file'];
1277
+ if (typeof file === 'string' && file.length > 0)
1278
+ seen.add(file);
1279
+ }
1280
+ return Array.from(seen);
1281
+ }
1282
+ return [];
1283
+ }
1164
1284
  //# sourceMappingURL=tool-bridge.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pugi hooks MVP — typed event payloads (Leak L12).
3
+ *
4
+ * This module ships the MINIMAL FIRST PASS of the user-config hook
5
+ * matrix. Two lifecycle events out of the eventual 8 land here:
6
+ *
7
+ * - `SessionStart` — fired once when the REPL boots (`session.ts`).
8
+ * - `PreToolUse` — fired before each tool dispatch
9
+ * (`engine/tool-bridge.ts`). Non-zero exit from a
10
+ * hook with `blocking: true` aborts the dispatch.
11
+ *
12
+ * The remaining 6 events (`PostToolUse`, `UserPromptSubmit`, `Stop`,
13
+ * `SubagentStop`, `PreCompact`, `Notification`) are deferred to a
14
+ * fast-follow PR. The pattern established here — discriminated union
15
+ * payloads + registry-driven dispatch — is the reusable template.
16
+ *
17
+ * Design note (parallel surface): an older surface lives at
18
+ * `apps/pugi-cli/src/core/hooks.ts` and uses a flat-array `hooks: [{
19
+ * event, match, run }]` config shape with per-hook events. THIS module
20
+ * adopts the Claude Code-style nested `hooks: { EventName: [{ matcher,
21
+ * command }] }` config shape. The two surfaces co-exist intentionally
22
+ * for the MVP — they read different files (`~/.pugi/hooks.json` vs.
23
+ * `~/.pugi/hooks-mvp.json`) so operator configs do not collide. The
24
+ * fast-follow PR consolidates the two readers.
25
+ *
26
+ * Brand voice: ASCII only, no emoji, no em-dashes, no marketing prose.
27
+ */
28
+ /** Events the MVP actually fires. The 6 deferred events live in the */
29
+ /** type but no integration point emits them yet. */
30
+ export const MVP_HOOK_EVENTS = [
31
+ 'SessionStart',
32
+ 'PreToolUse',
33
+ ];
34
+ export const ALL_HOOK_EVENTS_V2 = [
35
+ 'SessionStart',
36
+ 'PreToolUse',
37
+ 'PostToolUse',
38
+ 'UserPromptSubmit',
39
+ 'Stop',
40
+ 'SubagentStop',
41
+ 'PreCompact',
42
+ 'Notification',
43
+ ];
44
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Public surface of the MVP hooks module (Leak L12).
3
+ *
4
+ * Re-exports the registry, runner, and event types so callers can do
5
+ *
6
+ * import { loadHooksConfig, fireHooks } from '../core/hooks/index.js';
7
+ *
8
+ * without reaching into individual files. See `events.ts` for the
9
+ * scope note explaining why this MVP module co-exists with the older
10
+ * `src/core/hooks.ts` surface.
11
+ */
12
+ export { DEFAULT_HOOK_TIMEOUT_MS, HooksConfig, MAX_HOOK_TIMEOUT_MS, defaultHooksMvpPath, isToolEvent, loadHooksConfig, matchesTool, } from './registry.js';
13
+ export { fireHooks } from './runner.js';
14
+ export { ALL_HOOK_EVENTS_V2, MVP_HOOK_EVENTS, } from './events.js';
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Pugi hooks MVP — registry (Leak L12, first pass).
3
+ *
4
+ * Reads `<home>/hooks-mvp.json` and validates its shape with Zod. The
5
+ * file uses the Claude Code-style nested config:
6
+ *
7
+ * {
8
+ * "hooks": {
9
+ * "SessionStart": [{ "command": "echo session-start" }],
10
+ * "PreToolUse": [{ "matcher": "bash", "command": "echo bash-pre", "blocking": true }]
11
+ * }
12
+ * }
13
+ *
14
+ * Schema constraints:
15
+ * - Each hook entry MUST have a `command` (non-empty string).
16
+ * - `matcher` is optional. Defaults to `*` (any tool / any payload).
17
+ * For tool events, `matcher` is compared against the tool name.
18
+ * For non-tool events (SessionStart), `matcher` is ignored.
19
+ * - `timeoutMs` is optional. Defaults to 30 000 ms (per task spec).
20
+ * Capped at 60 000 ms to prevent operator-defined deadlocks.
21
+ * - `blocking` is optional. When true AND the hook exits non-zero,
22
+ * the registry surfaces an `anyBlocked: true` outcome so the
23
+ * caller can refuse the originating action. Only honoured for
24
+ * `PreToolUse` in the MVP — other events log but do not block.
25
+ *
26
+ * Failure modes:
27
+ * - File missing -> the registry is `empty()`. `list()` returns []
28
+ * and `fire()` is a no-op. This matches the Claude Code default
29
+ * (hooks are opt-in).
30
+ * - File present but invalid JSON / fails schema -> `load()` throws.
31
+ * The CLI surface (`pugi hooks doctor`) reports the error
32
+ * verbatim so the operator can fix the config.
33
+ *
34
+ * Brand voice: ASCII only, no emoji, no em-dashes.
35
+ */
36
+ import { existsSync, readFileSync } from 'node:fs';
37
+ import { homedir } from 'node:os';
38
+ import { resolve } from 'node:path';
39
+ import { z } from 'zod';
40
+ import { ALL_HOOK_EVENTS_V2 } from './events.js';
41
+ /** Default per-hook timeout when the operator does not set `timeoutMs`. */
42
+ export const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
43
+ /** Hard upper bound on `timeoutMs`. Prevents config-defined deadlocks. */
44
+ export const MAX_HOOK_TIMEOUT_MS = 60_000;
45
+ const hookEntrySchema = z
46
+ .object({
47
+ /**
48
+ * Tool-name matcher. `*` matches any tool. Plain strings match
49
+ * exactly (no glob in the MVP — fast-follow widens to glob). Ignored
50
+ * for non-tool events such as `SessionStart`.
51
+ */
52
+ matcher: z.string().min(1).optional(),
53
+ /** Shell command. Spawned via `/bin/sh -c <command>`. */
54
+ command: z.string().min(1),
55
+ /** Per-hook timeout override. Defaults to 30 000 ms. */
56
+ timeoutMs: z.number().int().positive().max(MAX_HOOK_TIMEOUT_MS).optional(),
57
+ /**
58
+ * When true, a non-zero exit code from this hook blocks the
59
+ * originating action (currently `PreToolUse` only). Other events
60
+ * log the exit but do not block.
61
+ */
62
+ blocking: z.boolean().optional(),
63
+ })
64
+ .strict();
65
+ const hookEventEnum = z.enum([
66
+ 'SessionStart',
67
+ 'PreToolUse',
68
+ 'PostToolUse',
69
+ 'UserPromptSubmit',
70
+ 'Stop',
71
+ 'SubagentStop',
72
+ 'PreCompact',
73
+ 'Notification',
74
+ ]);
75
+ const hooksFileSchema = z
76
+ .object({
77
+ hooks: z.record(hookEventEnum, z.array(hookEntrySchema)).default({}),
78
+ })
79
+ .strict();
80
+ /** Default config file location — `~/.pugi/hooks-mvp.json`. */
81
+ export function defaultHooksMvpPath(home) {
82
+ const root = home ?? process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
83
+ return resolve(root, 'hooks-mvp.json');
84
+ }
85
+ /**
86
+ * In-memory snapshot of the operator's `hooks-mvp.json`. Construct
87
+ * via `loadHooksConfig(path)` — `new HooksConfig()` is intentionally
88
+ * not exported so all production code paths go through the loader.
89
+ */
90
+ export class HooksConfig {
91
+ path;
92
+ entries;
93
+ constructor(path, entries) {
94
+ this.path = path;
95
+ this.entries = entries;
96
+ }
97
+ /** Absolute path of the config file this snapshot was loaded from. */
98
+ configPath() {
99
+ return this.path;
100
+ }
101
+ /** All hooks declared for a given event. Returns [] when none. */
102
+ list(event) {
103
+ return this.entries[event] ?? [];
104
+ }
105
+ /**
106
+ * Hooks that match the (event, toolName?) tuple. For tool events
107
+ * (`PreToolUse`), `matcher` is compared against the tool name with
108
+ * `*` matching any. For non-tool events, all entries are returned
109
+ * regardless of `matcher`.
110
+ */
111
+ listMatching(event, toolName) {
112
+ const all = this.list(event);
113
+ if (!isToolEvent(event))
114
+ return all;
115
+ return all.filter((entry) => matchesTool(entry.matcher, toolName));
116
+ }
117
+ /** Flat list of (event, entry) pairs across every configured event. */
118
+ flatten() {
119
+ const out = [];
120
+ for (const event of ALL_HOOK_EVENTS_V2) {
121
+ for (const entry of this.list(event)) {
122
+ out.push({ event, entry });
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+ /** True iff at least one hook is registered for any event. */
128
+ isEmpty() {
129
+ return this.flatten().length === 0;
130
+ }
131
+ /** A no-op snapshot used when the config file is absent. */
132
+ static empty(path) {
133
+ return new HooksConfig(path, {});
134
+ }
135
+ }
136
+ /**
137
+ * Load + validate `hooks-mvp.json`. Returns a no-op snapshot when the
138
+ * file is absent. Throws on invalid JSON or schema violations — the
139
+ * caller is expected to surface the error to the operator via
140
+ * `pugi hooks doctor`.
141
+ *
142
+ * Contract (non-null invariant): this function ALWAYS returns a
143
+ * `HooksConfig` instance. It never returns `null` / `undefined`. When
144
+ * the config file is missing, callers receive `HooksConfig.empty(path)`
145
+ * — a truthy snapshot for which `isEmpty()` returns `true` and `list()`
146
+ * returns `[]`. Callers may safely chain `.isEmpty()` without a null
147
+ * guard. Asserted by `registry-empty.spec.ts`.
148
+ */
149
+ export function loadHooksConfig(pathOverride) {
150
+ const path = pathOverride ?? defaultHooksMvpPath();
151
+ if (!existsSync(path)) {
152
+ return HooksConfig.empty(path);
153
+ }
154
+ let raw;
155
+ try {
156
+ raw = readFileSync(path, 'utf8');
157
+ }
158
+ catch (error) {
159
+ throw new Error(`pugi hooks: cannot read ${path}: ${error.message}`);
160
+ }
161
+ let parsed;
162
+ try {
163
+ parsed = JSON.parse(raw);
164
+ }
165
+ catch (error) {
166
+ throw new Error(`pugi hooks: ${path} is not valid JSON: ${error.message}`);
167
+ }
168
+ const result = hooksFileSchema.safeParse(parsed);
169
+ if (!result.success) {
170
+ const issues = result.error.issues
171
+ .map((issue) => `${issue.path.join('.') || '<root>'} ${issue.message}`)
172
+ .join('; ');
173
+ throw new Error(`pugi hooks: ${path} failed schema validation: ${issues}`);
174
+ }
175
+ // Zod's `z.record(enum, value)` returns `Partial<Record<...>>` shape
176
+ // — keys that the operator did not include are `undefined`. Coerce
177
+ // explicitly into the same shape `HooksConfig` expects.
178
+ const entries = {};
179
+ for (const event of ALL_HOOK_EVENTS_V2) {
180
+ const list = result.data.hooks[event];
181
+ if (list && list.length > 0) {
182
+ entries[event] = list;
183
+ }
184
+ }
185
+ return new HooksConfig(path, entries);
186
+ }
187
+ /**
188
+ * `isToolEvent(event)` -> true for events where `matcher` is compared
189
+ * against the tool name. SessionStart / Stop / Notification / etc. do
190
+ * not have an associated tool so matcher is ignored.
191
+ */
192
+ export function isToolEvent(event) {
193
+ return event === 'PreToolUse' || event === 'PostToolUse';
194
+ }
195
+ /**
196
+ * Tool-name match grammar for the MVP. Intentionally narrow:
197
+ * - matcher missing or `*` -> matches any tool name (and the
198
+ * `bash`/`read`/... shape).
199
+ * - matcher === toolName -> exact match.
200
+ *
201
+ * Fast-follow widens this to glob via `picomatch` so operators can
202
+ * write `mcp__*` patterns. Deliberately not pulling in a glob lib for
203
+ * the MVP — the narrow grammar is enough to land the surface and the
204
+ * test matrix stays small.
205
+ */
206
+ export function matchesTool(matcher, toolName) {
207
+ if (!matcher || matcher === '*')
208
+ return true;
209
+ if (!toolName)
210
+ return false;
211
+ return matcher === toolName;
212
+ }
213
+ //# sourceMappingURL=registry.js.map