@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.
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/compact/summarizer.js +12 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/engine/native-pugi.js +67 -3
- package/dist/core/engine/tool-bridge.js +123 -3
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/memory-sync/queue.spec.js +105 -0
- package/dist/core/repl/session.js +73 -1
- package/dist/core/repl/slash-commands.js +20 -0
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/session.js +44 -0
- package/dist/core/settings.js +9 -0
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/runtime/cli.js +216 -0
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +25 -23
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/memory.spec.js +174 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +23 -0
- 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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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:
|
|
802
|
+
payload: { tool: name, arguments: argsRaw, ok: true, result: augmented.slice(0, 1024) },
|
|
767
803
|
});
|
|
768
804
|
}
|
|
769
|
-
return
|
|
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
|