@pugi/cli 0.1.0-beta.23 → 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.
Files changed (34) hide show
  1. package/dist/core/auto-update/channels.js +122 -0
  2. package/dist/core/auto-update/checker.js +241 -0
  3. package/dist/core/auto-update/state.js +235 -0
  4. package/dist/core/engine/compaction-hook.js +154 -0
  5. package/dist/core/engine/native-pugi.js +67 -3
  6. package/dist/core/engine/tool-bridge.js +123 -3
  7. package/dist/core/hooks/events.js +44 -0
  8. package/dist/core/hooks/index.js +15 -0
  9. package/dist/core/hooks/registry.js +213 -0
  10. package/dist/core/hooks/runner.js +236 -0
  11. package/dist/core/init/scaffold.js +195 -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/repl/codebase-survey.js +308 -0
  16. package/dist/core/repl/init-interview.js +457 -0
  17. package/dist/core/repl/onboarding-state.js +297 -0
  18. package/dist/core/repl/session.js +84 -0
  19. package/dist/core/repl/slash-commands.js +25 -0
  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/runtime/cli.js +170 -0
  28. package/dist/runtime/commands/hooks.js +184 -0
  29. package/dist/runtime/commands/lsp.js +25 -23
  30. package/dist/runtime/commands/repo-map.js +95 -0
  31. package/dist/runtime/commands/update.js +289 -0
  32. package/dist/runtime/version.js +1 -1
  33. package/dist/tui/repl-splash-mascot.js +19 -7
  34. package/package.json +3 -3
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Repo-map scanner — Leak L28 (2026-05-27).
3
+ *
4
+ * Walks the workspace via `fs.readdirSync` (sync, depth-first), filters
5
+ * to a recognised set of source-language extensions, and applies the
6
+ * shared `PugiIgnore` matcher so the same exclusion rules used by the
7
+ * three-tier context skeleton also gate the repo-map.
8
+ *
9
+ * Why a stand-alone scanner (vs. reusing the α6.5 skeleton walker):
10
+ *
11
+ * 1. The skeleton walker emits a flat `IndexArtifact[]` of every
12
+ * ignore-respecting file (markdown, configs, schemas, etc.) for
13
+ * the working-set heuristic. The repo-map ONLY needs source
14
+ * files — markdown headings and JSON keys are not "definitions"
15
+ * in the L28 sense. Filtering downstream is cheap, but the
16
+ * scanner gets to short-circuit on extension before stat'ing
17
+ * the file, which matters for monorepos with thousands of
18
+ * non-source artefacts (lockfiles, schemas, fixtures).
19
+ *
20
+ * 2. We need mtime + size per file so `cache.ts` can invalidate
21
+ * stale entries without re-parsing. The skeleton walker
22
+ * surfaces only paths.
23
+ *
24
+ * 3. The L28 contract caps the walk at `MAX_SRC_FILES` (5000) and
25
+ * individual files at `MAX_FILE_BYTES` (200 KiB). When the cap
26
+ * trips the scanner returns a `{ skipped: 'too-large' }`
27
+ * verdict rather than partial data — the consumer must decide
28
+ * whether to fall back to a no-op map or surface a hint к the
29
+ * operator. Surfacing partial data would silently bias the
30
+ * injected summary toward whichever subtree the walker happened
31
+ * to traverse first.
32
+ *
33
+ * The output is sorted (POSIX path string compare) so two runs over
34
+ * the same workspace produce byte-identical `repo-map.json` caches —
35
+ * `cache.ts` relies on stable ordering for its hash-free freshness
36
+ * check. POSIX-style separators are used in `relPath` regardless of
37
+ * platform so the cache file stays portable.
38
+ *
39
+ * Pure module surface: no logging, no network. Errors during readdir
40
+ * on a single subtree (permission denied, symlink loop) are swallowed
41
+ * and the walker continues — repo-map is a best-effort context
42
+ * enrichment, never a gate.
43
+ */
44
+ import { readdirSync, statSync } from 'node:fs';
45
+ import { join, posix, relative, resolve, sep } from 'node:path';
46
+ /**
47
+ * Hard ceiling on total source files surfaced by a single scan. The
48
+ * engine context budget is the binding constraint — a 5K-file repo
49
+ * already overflows the 2K-token injection cap so going higher buys
50
+ * nothing but walker latency. Repos above the cap fall back к the
51
+ * `{ skipped: 'too-large' }` verdict.
52
+ */
53
+ export const MAX_SRC_FILES = 5000;
54
+ /**
55
+ * Per-file size cap. Files larger than this are skipped — they are
56
+ * almost always generated (compiled JS, vendored libs, encoded blobs)
57
+ * and add noise without signal. The 200 KiB threshold mirrors the
58
+ * α6.5 skeleton walker's own `MAX_FILE_BYTES` so the two scans agree
59
+ * on "what counts as a source file".
60
+ */
61
+ export const MAX_FILE_BYTES = 200 * 1024;
62
+ /**
63
+ * Source-language extensions the extractor knows how to parse. Adding
64
+ * a language here without a matching extractor branch is a silent
65
+ * no-op (the file shows up в the scan but extracts zero symbols);
66
+ * the spec asserts the symmetry so a future PR cannot drift the two
67
+ * lists out of sync.
68
+ */
69
+ export const SUPPORTED_EXTENSIONS = Object.freeze([
70
+ '.ts',
71
+ '.tsx',
72
+ '.js',
73
+ '.jsx',
74
+ '.mjs',
75
+ '.cjs',
76
+ '.md',
77
+ '.mdx',
78
+ ]);
79
+ const defaultReaddir = (path) => readdirSync(path, { withFileTypes: true });
80
+ const defaultStat = (path) => {
81
+ const s = statSync(path);
82
+ return { size: s.size, mtimeMs: s.mtimeMs };
83
+ };
84
+ /**
85
+ * Walk the workspace once and return every source file the extractor
86
+ * is willing to parse. The function is deliberately synchronous —
87
+ * the underlying walks are CPU-bound, не I/O-bound, and the sync
88
+ * call avoids the promise overhead that dominates for thousands of
89
+ * small files. The L28 engine boot path runs this on a Node `setImmediate`
90
+ * so the main thread is not blocked.
91
+ */
92
+ export function scanRepoForMap(options) {
93
+ const root = resolve(options.root);
94
+ const readdir = options.readdir ?? defaultReaddir;
95
+ const stat = options.stat ?? defaultStat;
96
+ const maxFiles = options.maxFiles ?? MAX_SRC_FILES;
97
+ const maxFileBytes = options.maxFileBytes ?? MAX_FILE_BYTES;
98
+ const ignore = options.ignore;
99
+ const files = [];
100
+ let walked = 0;
101
+ let skippedLarge = 0;
102
+ let skippedIgnored = 0;
103
+ let tooLarge = false;
104
+ /**
105
+ * Depth-first recursion. We push dirs into a manual stack instead of
106
+ * recursing in JS because deep monorepos (Nx with 100+ packages)
107
+ * have approached the v8 default stack limit on Windows runners
108
+ * before; an explicit stack is one less thing to debug.
109
+ */
110
+ const stack = [root];
111
+ while (stack.length > 0) {
112
+ const dir = stack.pop();
113
+ let entries;
114
+ try {
115
+ entries = readdir(dir);
116
+ }
117
+ catch {
118
+ // Permission denied / symlink loop / mid-flight delete — keep
119
+ // walking. Repo-map is best-effort context, never a gate.
120
+ continue;
121
+ }
122
+ for (const entry of entries) {
123
+ const abs = join(dir, entry.name);
124
+ const isDir = entry.isDirectory();
125
+ if (ignore.isIgnored(abs, isDir)) {
126
+ skippedIgnored += 1;
127
+ continue;
128
+ }
129
+ if (isDir) {
130
+ stack.push(abs);
131
+ continue;
132
+ }
133
+ if (!entry.isFile()) {
134
+ // Symlinks, sockets, FIFOs etc. Skip silently — they are not
135
+ // source code and stat'ing them can throw on broken links.
136
+ continue;
137
+ }
138
+ walked += 1;
139
+ const ext = extOf(entry.name);
140
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
141
+ continue;
142
+ }
143
+ let statResult;
144
+ try {
145
+ statResult = stat(abs);
146
+ }
147
+ catch {
148
+ // File vanished between readdir and stat — skip.
149
+ continue;
150
+ }
151
+ if (statResult.size > maxFileBytes) {
152
+ skippedLarge += 1;
153
+ continue;
154
+ }
155
+ // Workspace-relative POSIX path. `relative` returns the host
156
+ // separator on Windows; normalise to forward slashes so the
157
+ // cache file is portable.
158
+ const rel = relative(root, abs).split(sep).join(posix.sep);
159
+ files.push({
160
+ relPath: rel,
161
+ absPath: abs,
162
+ ext,
163
+ sizeBytes: statResult.size,
164
+ mtimeMs: statResult.mtimeMs,
165
+ });
166
+ if (files.length > maxFiles) {
167
+ tooLarge = true;
168
+ break;
169
+ }
170
+ }
171
+ if (tooLarge)
172
+ break;
173
+ }
174
+ if (tooLarge) {
175
+ return {
176
+ ok: false,
177
+ root,
178
+ skipped: {
179
+ reason: 'too-large',
180
+ walked,
181
+ },
182
+ };
183
+ }
184
+ // Sort by POSIX path for stable cache output. Two runs over the
185
+ // same workspace yield byte-identical JSON so the cache hash check
186
+ // is a simple `mtime + size` per entry without a content digest.
187
+ files.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
188
+ return {
189
+ ok: true,
190
+ root,
191
+ files,
192
+ stats: {
193
+ walked,
194
+ kept: files.length,
195
+ skippedLarge,
196
+ skippedIgnored,
197
+ },
198
+ };
199
+ }
200
+ /**
201
+ * Lowercase extension including the leading dot, or '' when the
202
+ * filename has no extension. Mirrors `node:path.extname` semantics —
203
+ * inlined so the scanner has zero per-iteration call overhead.
204
+ */
205
+ function extOf(name) {
206
+ const dot = name.lastIndexOf('.');
207
+ if (dot < 0 || dot === 0)
208
+ return '';
209
+ return name.slice(dot).toLowerCase();
210
+ }
211
+ //# sourceMappingURL=scanner.js.map
@@ -18,6 +18,50 @@ export function openSession(root) {
18
18
  enabled,
19
19
  };
20
20
  }
21
+ /**
22
+ * Leak L12 MVP — fire the `SessionStart` lifecycle event for all hooks
23
+ * declared in `~/.pugi/hooks-mvp.json`. Single-call surface; the REPL
24
+ * boot path invokes this once after `openSession`. Best-effort: any
25
+ * failure (missing config, hook spawn error) is swallowed so a
26
+ * misconfigured hook can never crash the REPL.
27
+ *
28
+ * Returns the number of hooks that fired (0 when no config / no
29
+ * matching hooks). Tests assert on the return value as the
30
+ * single-call invariant.
31
+ */
32
+ export async function fireSessionStartMvp(session) {
33
+ try {
34
+ const { loadHooksConfig, fireHooks } = await import('./hooks/index.js');
35
+ // Defense-in-depth: `loadHooksConfig` is contractually non-null
36
+ // (returns `HooksConfig.empty(path)` when the file is absent), but
37
+ // the dynamic import boundary above can in principle return an
38
+ // unexpected shape if the module is mis-resolved at runtime. Guard
39
+ // the optional-chained `isEmpty()` call so a malformed loader can
40
+ // never raise `TypeError: Cannot read properties of undefined` and
41
+ // crash the REPL boot path. Belt-and-suspenders with the
42
+ // surrounding try/catch — the catch still swallows everything else.
43
+ const config = loadHooksConfig();
44
+ if (!config || config.isEmpty())
45
+ return 0;
46
+ const outcome = await fireHooks({
47
+ config,
48
+ event: 'SessionStart',
49
+ payload: {
50
+ event: 'SessionStart',
51
+ sessionId: session.id,
52
+ workspaceRoot: session.root,
53
+ startedAt: new Date().toISOString(),
54
+ },
55
+ workspaceRoot: session.root,
56
+ });
57
+ return outcome.results.length;
58
+ }
59
+ catch {
60
+ // SessionStart is never blocking — log nothing, return 0. A
61
+ // broken `hooks-mvp.json` is surfaced via `pugi hooks doctor`.
62
+ return 0;
63
+ }
64
+ }
21
65
  export function recordCommandStarted(session, command) {
22
66
  if (!session.enabled)
23
67
  return;
@@ -88,6 +88,15 @@ const pugiSettingsSchema = z.object({
88
88
  python: z.boolean().optional(),
89
89
  go: z.boolean().optional(),
90
90
  rust: z.boolean().optional(),
91
+ // Leak L15 (2026-05-27): post-edit auto-diagnostics. When `true`,
92
+ // a successful `edit`/`write`/`multi_edit` triggers a diagnostic
93
+ // pull on the touched file(s) and the result is appended to the
94
+ // tool envelope so the model can self-correct in the same turn.
95
+ // Off by default — the cold-start of `typescript-language-server`
96
+ // is heavy enough that we opt in explicitly until dogfood proves
97
+ // the throughput trade is worth it. Also enabled via env var
98
+ // `PUGI_LSP_POST_EDIT=1` for CI / one-off operator probes.
99
+ postEditDiagnostics: z.boolean().optional(),
91
100
  })
92
101
  .optional(),
93
102
  // β1 Pl9 (#74) — per-command budget overrides. Optional. Partial
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { statSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
5
6
  import { dirname, relative, resolve } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
@@ -36,6 +37,7 @@ import { runReport } from './commands/report.js';
36
37
  import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
37
38
  import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
38
39
  import { runStickersCommand } from './commands/stickers.js';
40
+ import { runRepoMapCommand } from './commands/repo-map.js';
39
41
  import { runReleaseNotesCommand, defaultReleaseNotesHome, } from './commands/release-notes.js';
40
42
  import { runUndoCommand } from './commands/undo.js';
41
43
  import { runCompactCommand } from './commands/compact.js';
@@ -44,6 +46,7 @@ import { BARE_MODE_BANNER, isBareMode, setBareMode, } from '../core/bare-mode/in
44
46
  import { runCostCommand } from './commands/cost.js';
45
47
  import { runShareCommand } from './commands/share.js';
46
48
  import { runSkillsCommand } from './commands/skills.js';
49
+ import { runHooksCommand } from './commands/hooks.js';
47
50
  import { installDefaultSkills } from '../core/skills/defaults.js';
48
51
  import { runAgentsCommand } from './commands/agents.js';
49
52
  import { runLspCommand } from './commands/lsp.js';
@@ -92,6 +95,7 @@ const handlers = {
92
95
  deploy: dispatchDeploy,
93
96
  doctor,
94
97
  explain: runEngineTask('explain'),
98
+ hooks: dispatchHooks,
95
99
  fix: runEngineTask('fix'),
96
100
  handoff,
97
101
  help,
@@ -126,6 +130,14 @@ const handlers = {
126
130
  skills: dispatchSkills,
127
131
  status,
128
132
  stickers,
133
+ // Leak L28 (2026-05-27): `pugi repo-map` walks the source tree,
134
+ // extracts top-level function / class / interface / type / enum
135
+ // declarations + JSDoc summaries, caches the result in
136
+ // `.pugi/repo-map.json`, and renders the compact markdown listing.
137
+ // Same builder powers the engine boot-time system-prompt injection
138
+ // — running the CLI command shows the operator EXACTLY what the
139
+ // engine would see.
140
+ 'repo-map': dispatchRepoMap,
129
141
  // Leak L21 (2026-05-27): in-CLI feedback collector. Shares the
130
142
  // same handler as the in-REPL `/feedback` slash; the wrapper just
131
143
  // routes TTY vs non-TTY before mounting Ink.
@@ -151,6 +163,12 @@ const handlers = {
151
163
  // handler, same flags. Operators trained on Claude Code expect either
152
164
  // verb to surface the per-model token + USD table.
153
165
  usage: dispatchCost,
166
+ // Leak L27 (2026-05-27): `pugi update` — channel-aware npm registry
167
+ // probe + optional npm install shell-out. Same handler powers the
168
+ // in-REPL `/update` slash via the session module. R2 atomic swap
169
+ // deferred to Phase 2 per the sprint plan; npm is the single
170
+ // distribution channel today.
171
+ update: dispatchUpdate,
154
172
  version,
155
173
  web: dispatchWeb,
156
174
  whoami,
@@ -363,6 +381,31 @@ async function dispatchStyle(args, flags, _session) {
363
381
  * The runner returns the code; we attach it to `process.exitCode` so
364
382
  * subsequent dispatch wrappers do not clobber it on success.
365
383
  */
384
+ /**
385
+ * Leak L12 (2026-05-27) — `pugi hooks` top-level dispatcher (MVP).
386
+ *
387
+ * Two subcommands:
388
+ * - `pugi hooks list` — show configured hooks per event.
389
+ * - `pugi hooks doctor` — validate `~/.pugi/hooks-mvp.json`.
390
+ *
391
+ * MVP scope: 2 events of 8 (SessionStart + PreToolUse). Remaining 6
392
+ * events (PostToolUse, UserPromptSubmit, Stop, SubagentStop,
393
+ * PreCompact, Notification) deferred to fast-follow PR. The runner
394
+ * pattern established here is reusable for those events without
395
+ * touching this dispatcher.
396
+ *
397
+ * Exit codes:
398
+ * 0 -> happy path.
399
+ * 1 -> config present but invalid (doctor only).
400
+ * 2 -> argument error / unknown subcommand.
401
+ */
402
+ async function dispatchHooks(args, flags, _session) {
403
+ const rc = await runHooksCommand(args, {
404
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
405
+ });
406
+ if (rc !== 0)
407
+ process.exitCode = rc;
408
+ }
366
409
  async function dispatchTheme(args, flags, _session) {
367
410
  const rc = await runThemeCommand(args, {
368
411
  workspaceRoot: process.cwd(),
@@ -988,6 +1031,10 @@ function parseArgs(argv) {
988
1031
  // bare invocation only surfaces new sections. Opt-in to force the
989
1032
  // full bundled changelog к re-render (clears the on-disk marker).
990
1033
  reset: false,
1034
+ // Leak L28 — `--refresh` for `pugi repo-map`. Default off so a
1035
+ // bare invocation hits the cache when mtime + size match; opt-in
1036
+ // for a cold rebuild from the source tree.
1037
+ refresh: false,
991
1038
  };
992
1039
  const args = [];
993
1040
  // Leak L22: scan for `--bare` BEFORE the early-return short-circuits
@@ -1082,6 +1129,22 @@ function parseArgs(argv) {
1082
1129
  // single consumer today.
1083
1130
  flags.reset = true;
1084
1131
  }
1132
+ else if (arg === '--refresh') {
1133
+ // Leak L28 — `pugi repo-map --refresh` busts the cache and
1134
+ // rebuilds the AST-light summary from a cold scan. Parsed
1135
+ // globally for symmetry with the rest of the flag grammar;
1136
+ // `runRepoMapCommand` is the single consumer today.
1137
+ flags.refresh = true;
1138
+ }
1139
+ else if (arg === '--format=json' || arg === '--format' && argv[index + 1] === 'json') {
1140
+ // Leak L28 — `pugi repo-map --format=json` is a per-command
1141
+ // synonym for the global `--json` flag. The L28 spec calls
1142
+ // out the `--format=json` shape explicitly so we accept it
1143
+ // verbatim and route through the existing JSON envelope.
1144
+ flags.json = true;
1145
+ if (arg === '--format')
1146
+ index += 1;
1147
+ }
1085
1148
  else if (arg === '--decompose') {
1086
1149
  // α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
1087
1150
  // it. Parsed globally for symmetry with the rest of the flag
@@ -1503,6 +1566,32 @@ const COMMAND_HELP_BODIES = {
1503
1566
  'Useful in shell scripts that need a human-confirm before a destructive',
1504
1567
  'step. Exits 0 on yes, 1 on no, 2 on cancel.',
1505
1568
  ],
1569
+ update: [
1570
+ 'pugi update — channel-aware @pugi/cli update check + install.',
1571
+ '',
1572
+ 'Polls npm registry dist-tags for a newer @pugi/cli on the configured',
1573
+ 'channel (stable / beta / canary). Without flags, prints the install',
1574
+ 'command and exits. With --apply, shells out to `npm install -g …`.',
1575
+ '',
1576
+ ' --check Non-interactive probe + JSON envelope.',
1577
+ ' --channel <name> Switch channel (stable | beta | canary) and probe.',
1578
+ ' Persisted to ~/.pugi/config.json::updateChannel.',
1579
+ ' --apply Shell out to `npm install -g @pugi/cli@<tag>`',
1580
+ ' after a y/n confirmation.',
1581
+ ' --yes, -y Skip the confirmation prompt on --apply.',
1582
+ ' --json Force JSON envelope (auto-on with --check).',
1583
+ '',
1584
+ 'Channel mapping: stable -> npm `latest`, beta -> npm `beta`,',
1585
+ 'canary -> npm `next`. Default channel is `beta` (Pugi currently',
1586
+ 'ships beta releases only).',
1587
+ '',
1588
+ 'Also available as /update from inside the REPL — slash form NEVER',
1589
+ 'spawns npm (would corrupt the running binary); it only prints the',
1590
+ 'install command for the operator к run after exit.',
1591
+ '',
1592
+ 'R2 atomic swap (sprint plan L27) deferred к Phase 2 — npm is the',
1593
+ 'only distribution channel today.',
1594
+ ],
1506
1595
  stickers: [
1507
1596
  'pugi stickers — show a Pugi brand sticker (gimmick).',
1508
1597
  '',
@@ -1668,6 +1757,53 @@ async function doctor(_args, flags, _session) {
1668
1757
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
1669
1758
  });
1670
1759
  }
1760
+ /**
1761
+ * `pugi update` — Leak L27 (2026-05-27). Channel-aware npm registry
1762
+ * probe + optional shell-out to `npm install -g @pugi/cli@<tag>`.
1763
+ *
1764
+ * Argument grammar:
1765
+ * pugi update -> probe + offer install command
1766
+ * pugi update --check -> probe + JSON envelope (scripted)
1767
+ * pugi update --channel <name> -> persist channel + probe
1768
+ * pugi update --apply [--yes] -> probe + shell out to npm
1769
+ * pugi update --json -> JSON envelope (any subcommand)
1770
+ *
1771
+ * The handler delegates to `runUpdateCommand` in
1772
+ * `runtime/commands/update.ts` so the in-REPL `/update` slash + the
1773
+ * top-level shell command share one channel-resolution + persistence
1774
+ * + probe surface. Exit codes:
1775
+ *
1776
+ * 0 — happy path (no update OR update completed OR probe-only)
1777
+ * 1 — install / probe failure with structured error
1778
+ * 2 — argument error (unknown flag, unknown channel)
1779
+ */
1780
+ async function dispatchUpdate(args, flags, _session) {
1781
+ const { parseUpdateArgs, runUpdateCommand, defaultSpawnInstaller } = await import('./commands/update.js');
1782
+ const parsed = parseUpdateArgs(args, { jsonDefault: flags.json });
1783
+ if ('error' in parsed) {
1784
+ writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
1785
+ process.exitCode = 2;
1786
+ return;
1787
+ }
1788
+ const envelope = await runUpdateCommand({
1789
+ cwd: process.cwd(),
1790
+ home: homedir(),
1791
+ env: process.env,
1792
+ flags: parsed,
1793
+ promptConfirm: async (question) => {
1794
+ const answer = await readSingleChoice(`${question} `);
1795
+ return /^y(es)?$/i.test(answer.trim());
1796
+ },
1797
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1798
+ spawnInstaller: defaultSpawnInstaller,
1799
+ });
1800
+ if (!envelope.ok) {
1801
+ // `apply_cancelled_by_operator` is a benign decline; we still
1802
+ // surface a non-zero exit so scripted callers can detect that the
1803
+ // operator did not green-light the install.
1804
+ process.exitCode = 1;
1805
+ }
1806
+ }
1671
1807
  /**
1672
1808
  * `pugi status` — Leak L34 (2026-05-27). Concise session-state probe
1673
1809
  * mirroring Claude Code's `/status`. Distinct from `pugi doctor`
@@ -1716,6 +1852,27 @@ async function stickers(_args, flags, _session) {
1716
1852
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
1717
1853
  });
1718
1854
  }
1855
+ /**
1856
+ * `pugi repo-map` — Leak L28 (2026-05-27). Builds + caches the AST-
1857
+ * light symbol summary of the workspace. The handler is intentionally
1858
+ * thin: argv tail tokens are honoured for `--refresh` symmetry (the
1859
+ * global parser already sets `flags.refresh`, but accepting the flag
1860
+ * positionally lets `pugi repo-map refresh` work too — both forms
1861
+ * land в the same path). Exit code is always 0 (informational).
1862
+ *
1863
+ * The same builder is invoked lazily on engine boot when `--bare` is
1864
+ * not set; running the CLI command shows the operator EXACTLY what
1865
+ * the engine would inject into the system prompt.
1866
+ */
1867
+ async function dispatchRepoMap(args, flags, _session) {
1868
+ const refresh = flags.refresh || args.includes('--refresh') || args.includes('refresh');
1869
+ await runRepoMapCommand({
1870
+ cwd: process.cwd(),
1871
+ refresh,
1872
+ json: flags.json,
1873
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1874
+ });
1875
+ }
1719
1876
  /**
1720
1877
  * `pugi feedback` — Leak L21 (2026-05-27). In-CLI feedback collector.
1721
1878
  *
@@ -4108,6 +4265,19 @@ function runEngineTask(kind) {
4108
4265
  process.stderr.write(`pugi ${label}: MCP registry shutdown reported error — ${error.message}\n`);
4109
4266
  });
4110
4267
  }
4268
+ // Leak L15 (2026-05-27) — tear down any LSP servers warmed up
4269
+ // by the post-edit diagnostics cache. The cache is per-process
4270
+ // and survives across multiple tool calls; without this hook a
4271
+ // `pugi code ...` invocation would leak a tsserver process when
4272
+ // the Node host exits. The dynamic import keeps the cache module
4273
+ // out of the cold path for runs that never touch LSP.
4274
+ try {
4275
+ const { stopAllLspClients } = await import('../core/lsp/cache.js');
4276
+ await stopAllLspClients();
4277
+ }
4278
+ catch (error) {
4279
+ process.stderr.write(`pugi ${label}: LSP cache shutdown reported error — ${error.message}\n`);
4280
+ }
4111
4281
  }
4112
4282
  };
4113
4283
  }