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

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.
Files changed (41) hide show
  1. package/dist/core/checkpoint/resumer.js +149 -0
  2. package/dist/core/checkpoint/rewinder.js +291 -0
  3. package/dist/core/compact/summarizer.js +12 -0
  4. package/dist/core/dispatch/cache-cleanup.js +197 -0
  5. package/dist/core/dispatch/cache-handoff.js +295 -0
  6. package/dist/core/engine/native-pugi.js +67 -3
  7. package/dist/core/engine/tool-bridge.js +123 -3
  8. package/dist/core/hooks/events.js +44 -0
  9. package/dist/core/hooks/index.js +15 -0
  10. package/dist/core/hooks/registry.js +213 -0
  11. package/dist/core/hooks/runner.js +236 -0
  12. package/dist/core/lsp/cache.js +105 -0
  13. package/dist/core/lsp/language-detect.js +66 -0
  14. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  15. package/dist/core/memory-sync/queue.js +158 -0
  16. package/dist/core/memory-sync/queue.spec.js +105 -0
  17. package/dist/core/repl/session.js +73 -1
  18. package/dist/core/repl/slash-commands.js +20 -0
  19. package/dist/core/repl/store/session-store.js +31 -2
  20. package/dist/core/repo-map/build.js +125 -0
  21. package/dist/core/repo-map/cache.js +185 -0
  22. package/dist/core/repo-map/extractor.js +254 -0
  23. package/dist/core/repo-map/formatter.js +145 -0
  24. package/dist/core/repo-map/scanner.js +211 -0
  25. package/dist/core/session.js +44 -0
  26. package/dist/core/settings.js +9 -0
  27. package/dist/core/telemetry/emitter.js +229 -0
  28. package/dist/core/telemetry/queue.js +251 -0
  29. package/dist/runtime/cli.js +216 -0
  30. package/dist/runtime/commands/dispatch.js +126 -0
  31. package/dist/runtime/commands/hooks.js +184 -0
  32. package/dist/runtime/commands/lsp.js +25 -23
  33. package/dist/runtime/commands/memory.js +508 -0
  34. package/dist/runtime/commands/memory.spec.js +174 -0
  35. package/dist/runtime/commands/repo-map.js +95 -0
  36. package/dist/runtime/commands/resume.js +118 -0
  37. package/dist/runtime/commands/rewind.js +333 -0
  38. package/dist/runtime/commands/sessions.js +163 -0
  39. package/dist/runtime/version.js +1 -1
  40. package/dist/tools/agent-tool.js +23 -0
  41. package/package.json +2 -2
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Fork-subagent prompt-cache inheritance (Leak L10 — 2026-05-27).
3
+ *
4
+ * Claude Code's leaked sub-agent spawn pattern: when a parent agent
5
+ * dispatches a child via the Task tool, the child boots with the parent's
6
+ * prompt cache REFERENCE inherited — not the conversation transcript
7
+ * verbatim, but a provider-native cache handle that lets the child reuse
8
+ * the parent's system prompt + cached tool definitions without re-paying
9
+ * the prompt-prefix tokens on the first turn.
10
+ *
11
+ * Anthropic's `cache_control` block in their messages API exposes this
12
+ * directly via cache breakpoints. OpenAI / xAI / Gemini have similar
13
+ * primitives at different granularities; the wire payload our Anvil
14
+ * proxy speaks is provider-agnostic, so we forward a generic
15
+ * `parent_cache_id` hint and Anvil routes it onto the underlying
16
+ * provider's cache primitive (or silently drops it if the provider
17
+ * doesn't support cache inheritance — graceful degrade).
18
+ *
19
+ * What this module does:
20
+ *
21
+ * 1. `inheritCacheContext(parentSessionId, childAgentId)` —
22
+ * synthesises a cache handle for a child dispatch. Persists the
23
+ * handle to `.pugi/cache-refs/<child-agent-id>.json` so:
24
+ *
25
+ * a. The child's own boot path can read it (e.g. a child engine
26
+ * loop running in a worktree subshell where the in-memory
27
+ * DispatcherContext isn't reachable).
28
+ * b. `pugi dispatch list-cache-refs` can surface active refs
29
+ * for debugging "why is my cache hit rate so low".
30
+ * c. `pugi dispatch clear-cache-refs --older-than 1h` can
31
+ * garbage-collect stale refs from crashed/killed children.
32
+ *
33
+ * 2. `readCacheRef(workspaceRoot, childAgentId)` — child-side read.
34
+ * Returns the persisted handle so the child's first engine loop
35
+ * turn can include the parent_cache_id hint in its request.
36
+ *
37
+ * 3. `cacheHandoffHookForRequest(handle)` — produces the wire payload
38
+ * shape that the Anvil bridge can splice into the
39
+ * `engineLoopServerRequest` body. Lives here (not in the bridge)
40
+ * so the cache-control schema is in one place.
41
+ *
42
+ * What this module does NOT do:
43
+ *
44
+ * - It does not replay the parent's conversation transcript into the
45
+ * child. That would defeat the cyber-zoo isolation contract
46
+ * (`shared_fs_readonly` etc.). The child gets a CACHE HINT — the
47
+ * provider may use it to skip re-tokenising shared prompt prefix,
48
+ * but the LOGICAL conversation always starts fresh from the child's
49
+ * own system prompt + brief.
50
+ *
51
+ * - It does not assume any provider honours the hint. Cache-miss is
52
+ * the default path. If Anvil routes the request to a model whose
53
+ * provider doesn't expose cache inheritance, the request still
54
+ * succeeds — just at full prompt-prefix cost.
55
+ *
56
+ * - It does not rotate or invalidate the parent's cache on the
57
+ * parent's side. Parent cache lifecycle is owned by the parent
58
+ * engine loop; the child holds a read-only reference.
59
+ *
60
+ * Cross-reference:
61
+ * - apps/pugi-cli/src/core/subagents/dispatcher-real.ts — where the
62
+ * child engine loop is driven; the cache_id from this module is
63
+ * forwarded onto Anvil via the `extensions` field of the engine
64
+ * loop server request (β2 forward-compat slot).
65
+ * - packages/pugi-sdk/src/engine-loop.ts — engineLoopServerRequest
66
+ * schema; cache-handoff field is OPTIONAL and additive.
67
+ *
68
+ * Leak research §L10 (Claude Code sub-agent spawn fork pattern):
69
+ * docs/research/2026-05-27-leak-parity-sprint.md (research memo).
70
+ */
71
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
72
+ import { join, resolve as resolvePath } from 'node:path';
73
+ import { randomUUID } from 'node:crypto';
74
+ import { z } from 'zod';
75
+ /* ------------------------------------------------------------------ */
76
+ /* Types + schema */
77
+ /* ------------------------------------------------------------------ */
78
+ /**
79
+ * Persisted cache reference. The shape is intentionally minimal —
80
+ * provider-specific cache tokens are opaque strings so the file format
81
+ * does not couple to any one provider's API.
82
+ *
83
+ * - `cacheId` — opaque provider hint forwarded to Anvil.
84
+ * Synthesized client-side as `pugi-cache-<uuid>`
85
+ * so the server has a stable correlation key
86
+ * even when the underlying provider's cache
87
+ * object is not yet provisioned.
88
+ * - `contextRef` — logical reference key the parent uses to tag
89
+ * which prompt segments to re-use. Same value
90
+ * as cacheId for the v1 wire (single breakpoint
91
+ * per parent); reserved for future multi-breakpoint
92
+ * schemes (split system + tools + first-user
93
+ * segments).
94
+ * - `parentSessionId` — debugging / GC scoping. `list-cache-refs`
95
+ * can group by parent session.
96
+ * - `childAgentId` — the dispatch this ref is scoped to.
97
+ * - `createdAt` — ISO timestamp for `--older-than` cleanup.
98
+ * - `schemaVersion` — pinned to 1; bumped on breaking shape change.
99
+ */
100
+ export const cacheRefSchema = z.object({
101
+ schemaVersion: z.literal(1),
102
+ cacheId: z.string().min(1).max(256),
103
+ contextRef: z.string().min(1).max(256),
104
+ parentSessionId: z.string().min(1).max(256),
105
+ childAgentId: z.string().min(1).max(256),
106
+ createdAt: z.string().min(1),
107
+ });
108
+ /* ------------------------------------------------------------------ */
109
+ /* Path resolution */
110
+ /* ------------------------------------------------------------------ */
111
+ /**
112
+ * Resolve the directory under `.pugi/` where cache refs live. The
113
+ * directory is created on first write — readers tolerate its absence
114
+ * (no refs = empty list, not an error).
115
+ */
116
+ export function cacheRefDir(workspaceRoot) {
117
+ return resolvePath(workspaceRoot, '.pugi', 'cache-refs');
118
+ }
119
+ function cacheRefPath(workspaceRoot, childAgentId) {
120
+ // Sanitise the child id — directory traversal via crafted child id
121
+ // would let a dispatch target write outside the cache-refs dir. The
122
+ // child id format is owned by spawn.ts (`subagent-<uuid>`) but we
123
+ // defend in depth.
124
+ const safe = childAgentId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 200);
125
+ return join(cacheRefDir(workspaceRoot), `${safe}.json`);
126
+ }
127
+ /**
128
+ * Synthesize a cache handle for a child dispatch and persist it to
129
+ * `.pugi/cache-refs/<childAgentId>.json`. Returns the in-memory handle
130
+ * the dispatcher feeds into the Anvil wire request.
131
+ *
132
+ * Idempotent: calling twice with the same `childAgentId` overwrites
133
+ * the existing ref. The dispatcher's `taskId` is a UUID per dispatch,
134
+ * so collisions are not expected, but the overwrite semantic is safer
135
+ * than failing — a stale ref from a previous run (e.g. process killed
136
+ * between spawn and run) should not block a fresh dispatch.
137
+ */
138
+ export function inheritCacheContext(parentSessionId, childAgentId, options) {
139
+ if (!parentSessionId) {
140
+ throw new Error('inheritCacheContext: parentSessionId must be non-empty');
141
+ }
142
+ if (!childAgentId) {
143
+ throw new Error('inheritCacheContext: childAgentId must be non-empty');
144
+ }
145
+ const now = options.now ?? defaultNow;
146
+ const cacheIdFactory = options.cacheIdFactory ?? defaultCacheId;
147
+ const cacheId = cacheIdFactory();
148
+ const ref = {
149
+ schemaVersion: 1,
150
+ cacheId,
151
+ contextRef: cacheId,
152
+ parentSessionId,
153
+ childAgentId,
154
+ createdAt: now(),
155
+ };
156
+ const dir = cacheRefDir(options.workspaceRoot);
157
+ mkdirSync(dir, { recursive: true });
158
+ const path = cacheRefPath(options.workspaceRoot, childAgentId);
159
+ writeFileSync(path, JSON.stringify(ref, null, 2), 'utf8');
160
+ return {
161
+ cacheId: ref.cacheId,
162
+ contextRef: ref.contextRef,
163
+ persistedPath: path,
164
+ };
165
+ }
166
+ /**
167
+ * Child-side read. Returns null when the ref is missing or malformed
168
+ * (the child boots without cache inheritance — degrade is silent so a
169
+ * corrupted ref does not block a dispatch).
170
+ *
171
+ * Malformed ref files are NOT auto-deleted by this read path — that's
172
+ * the cleanup command's job. A failed parse here just returns null so
173
+ * the dispatch proceeds at full prompt-prefix cost.
174
+ */
175
+ export function readCacheRef(workspaceRoot, childAgentId) {
176
+ const path = cacheRefPath(workspaceRoot, childAgentId);
177
+ let raw;
178
+ try {
179
+ raw = readFileSync(path, 'utf8');
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ const validated = cacheRefSchema.safeParse(parsed);
192
+ if (!validated.success)
193
+ return null;
194
+ return validated.data;
195
+ }
196
+ /**
197
+ * List every persisted cache ref under `.pugi/cache-refs/`. Used by
198
+ * `pugi dispatch list-cache-refs` and by GC sweeps in
199
+ * `cache-cleanup.ts`. Returns refs in deterministic
200
+ * (filename-ascending) order so the CLI output is stable.
201
+ *
202
+ * Malformed ref files are silently skipped — the cleanup command can
203
+ * surface them via a separate `--show-corrupt` flag if needed.
204
+ */
205
+ export function listCacheRefs(workspaceRoot) {
206
+ const dir = cacheRefDir(workspaceRoot);
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ const refs = [];
215
+ for (const entry of entries) {
216
+ const full = join(dir, entry);
217
+ let raw;
218
+ try {
219
+ raw = readFileSync(full, 'utf8');
220
+ }
221
+ catch {
222
+ continue;
223
+ }
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(raw);
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ const validated = cacheRefSchema.safeParse(parsed);
232
+ if (!validated.success)
233
+ continue;
234
+ refs.push(validated.data);
235
+ }
236
+ return refs;
237
+ }
238
+ /* ------------------------------------------------------------------ */
239
+ /* Wire-format hook */
240
+ /* ------------------------------------------------------------------ */
241
+ /**
242
+ * Project the in-memory handle (or persisted ref) onto the wire-format
243
+ * hint object that the Anvil bridge merges into the engine-loop
244
+ * request body. Pulled out so callers (dispatcher-real, future bridge
245
+ * adapters) all speak the same shape.
246
+ */
247
+ export function cacheHandoffHookForRequest(source) {
248
+ return {
249
+ parent_cache_id: source.cacheId,
250
+ cache_context_ref: source.contextRef,
251
+ };
252
+ }
253
+ /* ------------------------------------------------------------------ */
254
+ /* Internals */
255
+ /* ------------------------------------------------------------------ */
256
+ function defaultNow() {
257
+ return new Date().toISOString();
258
+ }
259
+ function defaultCacheId() {
260
+ return `pugi-cache-${randomUUID()}`;
261
+ }
262
+ /* ------------------------------------------------------------------ */
263
+ /* Stat helper (used by cache-cleanup.ts) */
264
+ /* ------------------------------------------------------------------ */
265
+ /**
266
+ * Exposed for cache-cleanup.ts so the GC sweep can read mtime without
267
+ * re-implementing the path-resolution logic. Returns null when the
268
+ * file is gone (a concurrent cleanup may have raced us — silent miss).
269
+ */
270
+ export function cacheRefMtime(workspaceRoot, childAgentId) {
271
+ try {
272
+ const stat = statSync(cacheRefPath(workspaceRoot, childAgentId));
273
+ return stat.mtime;
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ /**
280
+ * Delete a single cache ref. Returns true when the file existed and
281
+ * was removed, false when it was already gone. Used by both the
282
+ * `clear-cache-refs` CLI and by post-dispatch cleanup (a successful
283
+ * subagent run does not need the cache ref to outlive its dispatch).
284
+ */
285
+ export function deleteCacheRef(workspaceRoot, childAgentId) {
286
+ const path = cacheRefPath(workspaceRoot, childAgentId);
287
+ try {
288
+ rmSync(path, { force: false });
289
+ return true;
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
295
+ //# sourceMappingURL=cache-handoff.js.map
@@ -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