@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,251 @@
1
+ /**
2
+ * Telemetry batching queue — Wave 6 BIG TRACK 11 (PR-PUGI-OBSERVABILITY-STACK).
3
+ *
4
+ * The emitter (see `emitter.ts`) appends events to an in-memory buffer
5
+ * and a JSONL spill file. The queue's two-tier strategy:
6
+ *
7
+ * 1. In-memory buffer (`MAX_BUFFER`) → flushed every `FLUSH_INTERVAL_MS`
8
+ * OR on REPL exit OR when the buffer hits the cap.
9
+ * 2. JSONL spill (`<repoRoot>/.pugi/telemetry-queue.jsonl`) → drained
10
+ * on every flush attempt. Used when the in-memory buffer cannot
11
+ * reach the network (offline laptop, admin-api down).
12
+ *
13
+ * Failure semantics mirror the `feedback/queue.ts` pattern that landed
14
+ * in L21:
15
+ *
16
+ * - 200/201/204 → success, drop from spill
17
+ * - 404 → endpoint not deployed yet — keep
18
+ * - 5xx / network / abort → transient — keep + exponential backoff
19
+ * - other 4xx → permanent — drop (otherwise loop forever)
20
+ *
21
+ * The queue is intentionally simple: there are no concurrency primitives
22
+ * beyond filesystem `O_APPEND`. The CLI is single-process per REPL
23
+ * session; the JSONL spill survives a crash because every append is
24
+ * atomic at the OS level for line-sized writes on POSIX (Linux & macOS).
25
+ *
26
+ * Privacy:
27
+ *
28
+ * - Events drop into the queue only when telemetry consent ≠ `off`.
29
+ * The emitter consults `readTelemetryChoice()` before calling
30
+ * `enqueueTelemetry(...)`. This module does NOT re-check — keeping
31
+ * the consent gate at the emitter avoids double-decoding and
32
+ * centralises the audit point.
33
+ *
34
+ * - The spill file lives under `<repoRoot>/.pugi/` (workspace tier)
35
+ * so an operator who deletes the repo also wipes any unfortunate
36
+ * events that never made it to the server.
37
+ */
38
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
39
+ import { dirname, resolve } from 'node:path';
40
+ import { randomUUID } from 'node:crypto';
41
+ import { PUGI_CLI_VERSION } from '../../runtime/version.js';
42
+ /** Defaults — tunable via env without redeploy. */
43
+ export const MAX_BUFFER = 50;
44
+ export const FLUSH_INTERVAL_MS = 15_000;
45
+ export const SPILL_FILE_NAME = 'telemetry-queue.jsonl';
46
+ /** Hard cap on the spill file (events, not bytes). Prevents pathologic
47
+ * growth on a laptop that is offline for weeks. */
48
+ export const SPILL_MAX_LINES = 5_000;
49
+ /**
50
+ * Resolve the absolute spill file path. Pure — exposed for spec parity.
51
+ */
52
+ export function telemetryQueuePath(opts = {}) {
53
+ const root = opts.repoRoot ?? process.cwd();
54
+ const name = opts.spillFileName ?? SPILL_FILE_NAME;
55
+ return resolve(root, '.pugi', name);
56
+ }
57
+ /**
58
+ * Append one event to the on-disk spill. Atomic at the OS level for
59
+ * line-sized writes — multiple concurrent appenders never interleave
60
+ * half-records on POSIX. Caps the file at `SPILL_MAX_LINES` by silently
61
+ * dropping the OLDEST events (FIFO) on the rare overflow path.
62
+ */
63
+ export function spillEvent(ev, opts = {}) {
64
+ const path = telemetryQueuePath(opts);
65
+ mkdirSync(dirname(path), { recursive: true });
66
+ const line = `${JSON.stringify(ev)}\n`;
67
+ // Fast path: append-only. We only check the line count when the file
68
+ // already exists AND we suspect overflow. Reading + rewriting every
69
+ // append would dominate the cost.
70
+ if (existsSync(path)) {
71
+ const current = readFileSync(path, 'utf8');
72
+ const lineCount = countLines(current);
73
+ if (lineCount >= SPILL_MAX_LINES) {
74
+ // FIFO trim: keep the most-recent SPILL_MAX_LINES/2 events. The
75
+ // factor 2 amortises the rewrite across many appends.
76
+ const lines = current.split('\n').filter((l) => l.length > 0);
77
+ const keep = lines.slice(lines.length - Math.floor(SPILL_MAX_LINES / 2));
78
+ writeFileSync(path, `${keep.join('\n')}\n${line}`, 'utf8');
79
+ return;
80
+ }
81
+ }
82
+ appendFileSync(path, line, { encoding: 'utf8', mode: 0o600 });
83
+ }
84
+ /**
85
+ * Read + parse every spilled event. Returns the events plus a list of
86
+ * malformed lines (which are dropped silently — we never reject a
87
+ * parseable line just because an adjacent one is corrupt).
88
+ */
89
+ export function readSpill(opts = {}) {
90
+ const path = telemetryQueuePath(opts);
91
+ if (!existsSync(path))
92
+ return { events: [], malformed: 0 };
93
+ const raw = readFileSync(path, 'utf8');
94
+ if (raw.length === 0)
95
+ return { events: [], malformed: 0 };
96
+ const events = [];
97
+ let malformed = 0;
98
+ for (const line of raw.split('\n')) {
99
+ if (line.length === 0)
100
+ continue;
101
+ try {
102
+ const parsed = JSON.parse(line);
103
+ if (isTelemetryEvent(parsed)) {
104
+ events.push(parsed);
105
+ }
106
+ else {
107
+ malformed += 1;
108
+ }
109
+ }
110
+ catch {
111
+ malformed += 1;
112
+ }
113
+ }
114
+ return { events, malformed };
115
+ }
116
+ /**
117
+ * Atomically rewrite the spill with the given events (the unsubmitted
118
+ * remainder after a partial-success flush). Writing through a sibling
119
+ * tempfile + rename keeps the spill consistent across a crash mid-flush.
120
+ */
121
+ export function rewriteSpill(events, opts = {}) {
122
+ const path = telemetryQueuePath(opts);
123
+ mkdirSync(dirname(path), { recursive: true });
124
+ if (events.length === 0) {
125
+ // Empty spill — write an empty file so the next read short-circuits.
126
+ writeFileSync(path, '', { encoding: 'utf8', mode: 0o600 });
127
+ return;
128
+ }
129
+ const body = events.map((e) => JSON.stringify(e)).join('\n');
130
+ writeFileSync(path, `${body}\n`, { encoding: 'utf8', mode: 0o600 });
131
+ }
132
+ /**
133
+ * Type guard for inbound spill lines. Keeps the queue robust against a
134
+ * forward-incompatible event shape (e.g. a future version added a
135
+ * required field) — anything that fails the guard is treated as
136
+ * malformed and dropped on parse.
137
+ */
138
+ export function isTelemetryEvent(value) {
139
+ if (!value || typeof value !== 'object')
140
+ return false;
141
+ const v = value;
142
+ return (typeof v.sessionId === 'string'
143
+ && typeof v.cliVersion === 'string'
144
+ && typeof v.command === 'string'
145
+ && typeof v.kind === 'string'
146
+ && typeof v.ts === 'string');
147
+ }
148
+ /**
149
+ * Exponential-backoff schedule. Returns the next delay (in ms) given an
150
+ * attempt counter, capped at `MAX_BACKOFF_MS`. Pure — exposed for tests.
151
+ *
152
+ * attempt 0 → 1s
153
+ * attempt 1 → 2s
154
+ * attempt 2 → 4s
155
+ * attempt 5 → 32s
156
+ * attempt 7+ → 60s (cap)
157
+ */
158
+ export const BACKOFF_BASE_MS = 1000;
159
+ export const MAX_BACKOFF_MS = 60_000;
160
+ export function backoffDelay(attempt) {
161
+ if (!Number.isFinite(attempt) || attempt < 0)
162
+ return BACKOFF_BASE_MS;
163
+ const exp = BACKOFF_BASE_MS * Math.pow(2, Math.floor(attempt));
164
+ return Math.min(MAX_BACKOFF_MS, exp);
165
+ }
166
+ const DEFAULT_FLUSH_TIMEOUT_MS = 8_000;
167
+ export function telemetryIngestUrl(apiUrl) {
168
+ const base = apiUrl.replace(/\/+$/u, '');
169
+ return `${base}/api/pugi/telemetry/event`;
170
+ }
171
+ /**
172
+ * POST one batch. Same result-variant contract as
173
+ * `feedback/submitter.submitFeedback`. Never throws.
174
+ */
175
+ export async function postTelemetryBatch(events, config) {
176
+ if (events.length === 0) {
177
+ return { kind: 'ok', httpStatus: 204, accepted: 0, dropped: 0 };
178
+ }
179
+ const url = telemetryIngestUrl(config.apiUrl);
180
+ const fetchImpl = config.fetchImpl ?? fetch;
181
+ const timeoutMs = config.timeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
182
+ const controller = new AbortController();
183
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
184
+ try {
185
+ const headers = {
186
+ 'content-type': 'application/json',
187
+ 'user-agent': `pugi-cli/${PUGI_CLI_VERSION}`,
188
+ };
189
+ if (config.apiKey)
190
+ headers['authorization'] = `Bearer ${config.apiKey}`;
191
+ const res = await fetchImpl(url, {
192
+ method: 'POST',
193
+ headers,
194
+ body: JSON.stringify({ events }),
195
+ signal: controller.signal,
196
+ });
197
+ const status = res.status;
198
+ if (status >= 200 && status < 300) {
199
+ let accepted = events.length;
200
+ let dropped = 0;
201
+ try {
202
+ const body = (await res.json());
203
+ if (typeof body.accepted === 'number')
204
+ accepted = body.accepted;
205
+ if (typeof body.dropped === 'number')
206
+ dropped = body.dropped;
207
+ }
208
+ catch {
209
+ // Body absent / not JSON — server still acked 2xx, treat as full success.
210
+ }
211
+ return { kind: 'ok', httpStatus: status, accepted, dropped };
212
+ }
213
+ if (status === 404) {
214
+ return {
215
+ kind: 'transient',
216
+ reason: 'admin-api /api/pugi/telemetry/event not deployed yet',
217
+ httpStatus: status,
218
+ };
219
+ }
220
+ if (status >= 500) {
221
+ return { kind: 'transient', reason: `server error ${status}`, httpStatus: status };
222
+ }
223
+ return { kind: 'permanent', reason: `client error ${status}`, httpStatus: status };
224
+ }
225
+ catch (err) {
226
+ const message = err instanceof Error ? err.message : String(err);
227
+ return { kind: 'transient', reason: `network: ${message}` };
228
+ }
229
+ finally {
230
+ clearTimeout(timer);
231
+ }
232
+ }
233
+ // ---------------------------------------------------------------------
234
+ // Helpers
235
+ // ---------------------------------------------------------------------
236
+ function countLines(s) {
237
+ let n = 0;
238
+ for (let i = 0; i < s.length; i += 1) {
239
+ if (s.charCodeAt(i) === 10)
240
+ n += 1;
241
+ }
242
+ return n;
243
+ }
244
+ /**
245
+ * Generate a session id for the REPL boot. UUID v4 — short enough to
246
+ * grep, long enough to be globally unique across concurrent processes.
247
+ */
248
+ export function newSessionId() {
249
+ return randomUUID();
250
+ }
251
+ //# sourceMappingURL=queue.js.map
@@ -22,6 +22,7 @@ import { buildRuntimeConfig, fetchPersonaRoster, loadRuntimeConfig, openPugiSess
22
22
  import { PUGI_TAGLINE } from '@pugi/personas';
23
23
  import { resolveRoster, renderRosterTable } from './commands/roster.js';
24
24
  import { runDelegateCommand } from './commands/delegate.js';
25
+ import { runDispatchCommand } from './commands/dispatch.js';
25
26
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
26
27
  import { resolveAndValidateEnvLogin, } from '../core/auth/env-provider.js';
27
28
  import { runDeployCommand } from '../commands/deploy.js';
@@ -37,14 +38,22 @@ import { runReport } from './commands/report.js';
37
38
  import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
38
39
  import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
39
40
  import { runStickersCommand } from './commands/stickers.js';
41
+ import { runRepoMapCommand } from './commands/repo-map.js';
40
42
  import { runReleaseNotesCommand, defaultReleaseNotesHome, } from './commands/release-notes.js';
41
43
  import { runUndoCommand } from './commands/undo.js';
42
44
  import { runCompactCommand } from './commands/compact.js';
45
+ import { runRewindCommand } from './commands/rewind.js';
46
+ import { runSessionsCommand } from './commands/sessions.js';
47
+ // Day 4 ADR-0063: persona-memory operator surface (list / recall / write /
48
+ // forget / sync). The runner is shared by `pugi memory` top-level and the
49
+ // in-REPL `/memory` slash so the two surfaces stay single-sourced.
50
+ import { runMemoryCommand } from './commands/memory.js';
43
51
  import { runBudgetCommand } from './commands/budget.js';
44
52
  import { BARE_MODE_BANNER, isBareMode, setBareMode, } from '../core/bare-mode/index.js';
45
53
  import { runCostCommand } from './commands/cost.js';
46
54
  import { runShareCommand } from './commands/share.js';
47
55
  import { runSkillsCommand } from './commands/skills.js';
56
+ import { runHooksCommand } from './commands/hooks.js';
48
57
  import { installDefaultSkills } from '../core/skills/defaults.js';
49
58
  import { runAgentsCommand } from './commands/agents.js';
50
59
  import { runLspCommand } from './commands/lsp.js';
@@ -90,9 +99,15 @@ const handlers = {
90
99
  config: dispatchConfig,
91
100
  cost: dispatchCost,
92
101
  delegate: dispatchDelegate,
102
+ // Leak L10 (2026-05-27): `pugi dispatch list-cache-refs` /
103
+ // `clear-cache-refs` operate on `.pugi/cache-refs/` — the persisted
104
+ // prompt-cache inheritance handles for fork-subagent dispatches. The
105
+ // handler module lives in commands/dispatch.ts so the table stays narrow.
106
+ dispatch: dispatchSubagentCacheRefs,
93
107
  deploy: dispatchDeploy,
94
108
  doctor,
95
109
  explain: runEngineTask('explain'),
110
+ hooks: dispatchHooks,
96
111
  fix: runEngineTask('fix'),
97
112
  handoff,
98
113
  help,
@@ -103,6 +118,10 @@ const handlers = {
103
118
  logout,
104
119
  lsp: dispatchLsp,
105
120
  mcp: dispatchMcp,
121
+ // ADR-0063 Day 4: `pugi memory list|recall|write|forget|sync`. Routes
122
+ // to `runMemoryCommand` (admin-api `/api/persona-memory` + offline
123
+ // queue at `~/.pugi/memory-queue.jsonl`).
124
+ memory: dispatchMemory,
106
125
  patch: dispatchPatch,
107
126
  permissions: dispatchPermissions,
108
127
  perms: dispatchPermissions,
@@ -127,6 +146,14 @@ const handlers = {
127
146
  skills: dispatchSkills,
128
147
  status,
129
148
  stickers,
149
+ // Leak L28 (2026-05-27): `pugi repo-map` walks the source tree,
150
+ // extracts top-level function / class / interface / type / enum
151
+ // declarations + JSDoc summaries, caches the result in
152
+ // `.pugi/repo-map.json`, and renders the compact markdown listing.
153
+ // Same builder powers the engine boot-time system-prompt injection
154
+ // — running the CLI command shows the operator EXACTLY what the
155
+ // engine would see.
156
+ 'repo-map': dispatchRepoMap,
130
157
  // Leak L21 (2026-05-27): in-CLI feedback collector. Shares the
131
158
  // same handler as the in-REPL `/feedback` slash; the wrapper just
132
159
  // routes TTY vs non-TTY before mounting Ink.
@@ -148,6 +175,11 @@ const handlers = {
148
175
  vim: dispatchVim,
149
176
  undo: dispatchUndo,
150
177
  compact: dispatchCompact,
178
+ // Leak L9 (2026-05-27): `pugi rewind [N | --to <id>]` rolls the
179
+ // conversation back to a checkpoint by appending a tombstone marker
180
+ // to the NDJSON event log. The slash counterpart `/rewind` forwards
181
+ // to the same runner via session.ts.
182
+ rewind: dispatchRewind,
151
183
  // L19 (2026-05-27): `pugi usage` is an alias of `pugi cost` — same
152
184
  // handler, same flags. Operators trained on Claude Code expect either
153
185
  // verb to surface the per-model token + USD table.
@@ -332,6 +364,41 @@ async function dispatchPrivacy(args, flags, _session) {
332
364
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
333
365
  });
334
366
  }
367
+ /**
368
+ * ADR-0063 Day 4 — `pugi memory <sub>` top-level dispatcher.
369
+ *
370
+ * Forwards to the shared `runMemoryCommand` runner. Exit codes:
371
+ *
372
+ * - 0 — happy paths (listed / recalled / written / forgot / synced /
373
+ * queued_offline / sync_noop / sync_partial)
374
+ * - 1 — unauthenticated / feature_disabled / unknown_sub
375
+ * - 2 — invalid_args
376
+ *
377
+ * `forget_not_found` exits 0 because the operator-visible behaviour
378
+ * (the memory is gone) matches their intent; the JSON envelope still
379
+ * carries the `forget_not_found` status flag for scripted callers.
380
+ */
381
+ async function dispatchMemory(args, flags, _session) {
382
+ const result = await runMemoryCommand(args, {
383
+ workspaceRoot: process.cwd(),
384
+ json: flags.json,
385
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
386
+ });
387
+ switch (result.status) {
388
+ case 'unauthenticated':
389
+ case 'feature_disabled':
390
+ case 'unknown_sub':
391
+ process.exitCode = 1;
392
+ return;
393
+ case 'invalid_args':
394
+ process.exitCode = 2;
395
+ return;
396
+ default:
397
+ // 'listed' | 'recalled' | 'written' | 'queued_offline' | 'forgot' |
398
+ // 'forget_not_found' | 'synced' | 'sync_partial' | 'sync_noop' — exit 0.
399
+ return;
400
+ }
401
+ }
335
402
  /**
336
403
  * Leak L18 (2026-05-27) — `pugi style` top-level dispatcher.
337
404
  *
@@ -370,6 +437,31 @@ async function dispatchStyle(args, flags, _session) {
370
437
  * The runner returns the code; we attach it to `process.exitCode` so
371
438
  * subsequent dispatch wrappers do not clobber it on success.
372
439
  */
440
+ /**
441
+ * Leak L12 (2026-05-27) — `pugi hooks` top-level dispatcher (MVP).
442
+ *
443
+ * Two subcommands:
444
+ * - `pugi hooks list` — show configured hooks per event.
445
+ * - `pugi hooks doctor` — validate `~/.pugi/hooks-mvp.json`.
446
+ *
447
+ * MVP scope: 2 events of 8 (SessionStart + PreToolUse). Remaining 6
448
+ * events (PostToolUse, UserPromptSubmit, Stop, SubagentStop,
449
+ * PreCompact, Notification) deferred to fast-follow PR. The runner
450
+ * pattern established here is reusable for those events without
451
+ * touching this dispatcher.
452
+ *
453
+ * Exit codes:
454
+ * 0 -> happy path.
455
+ * 1 -> config present but invalid (doctor only).
456
+ * 2 -> argument error / unknown subcommand.
457
+ */
458
+ async function dispatchHooks(args, flags, _session) {
459
+ const rc = await runHooksCommand(args, {
460
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
461
+ });
462
+ if (rc !== 0)
463
+ process.exitCode = rc;
464
+ }
373
465
  async function dispatchTheme(args, flags, _session) {
374
466
  const rc = await runThemeCommand(args, {
375
467
  workspaceRoot: process.cwd(),
@@ -534,6 +626,31 @@ async function dispatchBudget(args, flags, _session) {
534
626
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
535
627
  });
536
628
  }
629
+ /**
630
+ * Leak L9 (2026-05-27) — `pugi rewind [N | --to <id>]` rolls the
631
+ * conversation back to a checkpoint by appending a tombstone marker to
632
+ * the NDJSON event log. Append-only: events stay durable; `pugi
633
+ * sessions undo-rewind` reverses the operation. The slash `/rewind`
634
+ * forwards through this same runner via session.ts.
635
+ */
636
+ async function dispatchRewind(args, flags, _session) {
637
+ const result = await runRewindCommand(args, {
638
+ workspaceRoot: process.cwd(),
639
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
640
+ });
641
+ if (result.status === 'failed_no_session'
642
+ || result.status === 'failed_store') {
643
+ process.exitCode = 1;
644
+ return;
645
+ }
646
+ if (result.status === 'failed_parse') {
647
+ process.exitCode = 2;
648
+ return;
649
+ }
650
+ if (result.status === 'noop_zero' || result.status === 'noop_empty') {
651
+ process.exitCode = 2;
652
+ }
653
+ }
537
654
  /**
538
655
  * Leak L6 — `pugi permissions [mode] [--persist] [--confirm]`.
539
656
  *
@@ -709,6 +826,19 @@ async function dispatchAgents(args, flags, _session) {
709
826
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
710
827
  });
711
828
  }
829
+ /**
830
+ * Leak L10 (2026-05-27): `pugi dispatch <sub>` — operator-facing
831
+ * inspection + GC for fork-subagent prompt-cache inherit refs
832
+ * (.pugi/cache-refs/). Delegates to the standalone runner in
833
+ * commands/dispatch.ts so the cli.ts table stays under control.
834
+ */
835
+ async function dispatchSubagentCacheRefs(args, flags, _session) {
836
+ await runDispatchCommand(args, {
837
+ workspaceRoot: process.cwd(),
838
+ json: flags.json,
839
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
840
+ });
841
+ }
712
842
  /**
713
843
  * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
714
844
  *
@@ -995,6 +1125,10 @@ function parseArgs(argv) {
995
1125
  // bare invocation only surfaces new sections. Opt-in to force the
996
1126
  // full bundled changelog к re-render (clears the on-disk marker).
997
1127
  reset: false,
1128
+ // Leak L28 — `--refresh` for `pugi repo-map`. Default off so a
1129
+ // bare invocation hits the cache when mtime + size match; opt-in
1130
+ // for a cold rebuild from the source tree.
1131
+ refresh: false,
998
1132
  };
999
1133
  const args = [];
1000
1134
  // Leak L22: scan for `--bare` BEFORE the early-return short-circuits
@@ -1089,6 +1223,22 @@ function parseArgs(argv) {
1089
1223
  // single consumer today.
1090
1224
  flags.reset = true;
1091
1225
  }
1226
+ else if (arg === '--refresh') {
1227
+ // Leak L28 — `pugi repo-map --refresh` busts the cache and
1228
+ // rebuilds the AST-light summary from a cold scan. Parsed
1229
+ // globally for symmetry with the rest of the flag grammar;
1230
+ // `runRepoMapCommand` is the single consumer today.
1231
+ flags.refresh = true;
1232
+ }
1233
+ else if (arg === '--format=json' || arg === '--format' && argv[index + 1] === 'json') {
1234
+ // Leak L28 — `pugi repo-map --format=json` is a per-command
1235
+ // synonym for the global `--json` flag. The L28 spec calls
1236
+ // out the `--format=json` shape explicitly so we accept it
1237
+ // verbatim and route through the existing JSON envelope.
1238
+ flags.json = true;
1239
+ if (arg === '--format')
1240
+ index += 1;
1241
+ }
1092
1242
  else if (arg === '--decompose') {
1093
1243
  // α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
1094
1244
  // it. Parsed globally for symmetry with the rest of the flag
@@ -1470,6 +1620,17 @@ const COMMAND_HELP_BODIES = {
1470
1620
  'Slugs (Tier 1 alpha 7.5): dev qa pm devops researcher analyst designer',
1471
1621
  'frontend architect. `pugi roster` lists the live set.',
1472
1622
  ],
1623
+ dispatch: [
1624
+ 'pugi dispatch <sub> — inspect + GC fork-subagent prompt-cache inherit refs.',
1625
+ '',
1626
+ ' list-cache-refs Table of every active ref under .pugi/cache-refs/.',
1627
+ ' clear-cache-refs [--older-than 1h] Evict refs older than the window (default 24h).',
1628
+ '',
1629
+ 'Leak L10 (2026-05-27): when Mira spawns a child via the `agent` tool,',
1630
+ 'a prompt-cache handle is persisted so the child loop can request',
1631
+ 'parent-context reuse on the wire. These commands surface + clean up',
1632
+ 'the persisted refs.',
1633
+ ],
1473
1634
  roster: [
1474
1635
  'pugi roster — list the live Tier 1 personas + roles.',
1475
1636
  ],
@@ -1638,6 +1799,8 @@ async function help(args, flags, _session) {
1638
1799
  'Persona dispatch (α7.5):',
1639
1800
  ' pugi roster List the live Tier 1 personas + roles.',
1640
1801
  ' pugi delegate <slug> "<brief>" Dispatch a brief to one specialist.',
1802
+ ' pugi dispatch list-cache-refs Inspect fork-subagent prompt-cache inherit refs.',
1803
+ ' pugi dispatch clear-cache-refs GC stale cache refs (--older-than 1h).',
1641
1804
  '',
1642
1805
  'Plan decomposition (α6.8):',
1643
1806
  ' pugi plan --decompose <idea> Split a high-level idea into 3-7 components.',
@@ -1796,6 +1959,27 @@ async function stickers(_args, flags, _session) {
1796
1959
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
1797
1960
  });
1798
1961
  }
1962
+ /**
1963
+ * `pugi repo-map` — Leak L28 (2026-05-27). Builds + caches the AST-
1964
+ * light symbol summary of the workspace. The handler is intentionally
1965
+ * thin: argv tail tokens are honoured for `--refresh` symmetry (the
1966
+ * global parser already sets `flags.refresh`, but accepting the flag
1967
+ * positionally lets `pugi repo-map refresh` work too — both forms
1968
+ * land в the same path). Exit code is always 0 (informational).
1969
+ *
1970
+ * The same builder is invoked lazily on engine boot when `--bare` is
1971
+ * not set; running the CLI command shows the operator EXACTLY what
1972
+ * the engine would inject into the system prompt.
1973
+ */
1974
+ async function dispatchRepoMap(args, flags, _session) {
1975
+ const refresh = flags.refresh || args.includes('--refresh') || args.includes('refresh');
1976
+ await runRepoMapCommand({
1977
+ cwd: process.cwd(),
1978
+ refresh,
1979
+ json: flags.json,
1980
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1981
+ });
1982
+ }
1799
1983
  /**
1800
1984
  * `pugi feedback` — Leak L21 (2026-05-27). In-CLI feedback collector.
1801
1985
  *
@@ -3262,6 +3446,25 @@ async function handoff(args, flags, session) {
3262
3446
  writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
3263
3447
  }
3264
3448
  async function sessions(args, flags, _session) {
3449
+ // L9 (2026-05-27): `pugi sessions undo-rewind [<session-id>]` rolls
3450
+ // back the latest /rewind by appending an inverse marker. Append-only,
3451
+ // reversible. Falls through to the legacy artifact-based handler when
3452
+ // the sub-command is not recognised.
3453
+ if (args[0] === 'undo-rewind') {
3454
+ const result = await runSessionsCommand(args, {
3455
+ workspaceRoot: process.cwd(),
3456
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
3457
+ });
3458
+ if (result) {
3459
+ if (result.status === 'failed_no_session' || result.status === 'failed_store') {
3460
+ process.exitCode = 1;
3461
+ }
3462
+ else if (result.status === 'noop_no_rewind') {
3463
+ process.exitCode = 2;
3464
+ }
3465
+ return;
3466
+ }
3467
+ }
3265
3468
  // α6.4: `pugi sessions --local` / `--search "query"` route to the
3266
3469
  // local SessionStore. The default surface stays artifact-based for
3267
3470
  // backward compat — operators who relied on the index.json view get
@@ -4188,6 +4391,19 @@ function runEngineTask(kind) {
4188
4391
  process.stderr.write(`pugi ${label}: MCP registry shutdown reported error — ${error.message}\n`);
4189
4392
  });
4190
4393
  }
4394
+ // Leak L15 (2026-05-27) — tear down any LSP servers warmed up
4395
+ // by the post-edit diagnostics cache. The cache is per-process
4396
+ // and survives across multiple tool calls; without this hook a
4397
+ // `pugi code ...` invocation would leak a tsserver process when
4398
+ // the Node host exits. The dynamic import keeps the cache module
4399
+ // out of the cold path for runs that never touch LSP.
4400
+ try {
4401
+ const { stopAllLspClients } = await import('../core/lsp/cache.js');
4402
+ await stopAllLspClients();
4403
+ }
4404
+ catch (error) {
4405
+ process.stderr.write(`pugi ${label}: LSP cache shutdown reported error — ${error.message}\n`);
4406
+ }
4191
4407
  }
4192
4408
  };
4193
4409
  }