@pugi/cli 0.1.0-beta.3 → 0.1.0-beta.31

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 (219) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/artifact-chain/dispatcher.js +148 -0
  10. package/dist/core/artifact-chain/exporter.js +164 -0
  11. package/dist/core/artifact-chain/state.js +243 -0
  12. package/dist/core/artifact-chain/steps.js +169 -0
  13. package/dist/core/auth/env-provider.js +238 -0
  14. package/dist/core/auto-update/channels.js +122 -0
  15. package/dist/core/auto-update/checker.js +241 -0
  16. package/dist/core/auto-update/state.js +235 -0
  17. package/dist/core/bare-mode/index.js +107 -0
  18. package/dist/core/checkpoint/resumer.js +149 -0
  19. package/dist/core/checkpoint/rewinder.js +291 -0
  20. package/dist/core/compact/auto-trigger.js +96 -0
  21. package/dist/core/compact/buffer-rewriter.js +115 -0
  22. package/dist/core/compact/summarizer.js +208 -0
  23. package/dist/core/compact/token-counter.js +108 -0
  24. package/dist/core/consensus/diff-capture.js +73 -0
  25. package/dist/core/context/index.js +7 -0
  26. package/dist/core/context/markdown-traverse.js +255 -0
  27. package/dist/core/cost/rate-card.js +129 -0
  28. package/dist/core/cost/tracker.js +221 -0
  29. package/dist/core/denial-tracking/index.js +8 -0
  30. package/dist/core/denial-tracking/state.js +264 -0
  31. package/dist/core/diagnostics/probe-runner.js +93 -0
  32. package/dist/core/diagnostics/probes/api.js +46 -0
  33. package/dist/core/diagnostics/probes/auth.js +86 -0
  34. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  35. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  36. package/dist/core/diagnostics/probes/config.js +72 -0
  37. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  38. package/dist/core/diagnostics/probes/disk.js +81 -0
  39. package/dist/core/diagnostics/probes/git.js +65 -0
  40. package/dist/core/diagnostics/probes/mcp.js +75 -0
  41. package/dist/core/diagnostics/probes/node.js +59 -0
  42. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  43. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  44. package/dist/core/diagnostics/probes/session.js +74 -0
  45. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  46. package/dist/core/diagnostics/probes/workspace.js +63 -0
  47. package/dist/core/diagnostics/types.js +70 -0
  48. package/dist/core/dispatch/cache-cleanup.js +197 -0
  49. package/dist/core/dispatch/cache-handoff.js +295 -0
  50. package/dist/core/edits/dispatch.js +218 -2
  51. package/dist/core/edits/journal.js +199 -0
  52. package/dist/core/edits/layer-d-ast.js +557 -14
  53. package/dist/core/edits/verify-hook.js +273 -0
  54. package/dist/core/edits/worktree.js +111 -18
  55. package/dist/core/engine/anvil-client.js +115 -5
  56. package/dist/core/engine/budgets.js +89 -0
  57. package/dist/core/engine/context-prefix.js +155 -0
  58. package/dist/core/engine/intent.js +260 -0
  59. package/dist/core/engine/native-pugi.js +852 -210
  60. package/dist/core/engine/prompts.js +89 -6
  61. package/dist/core/engine/strip-internal-fields.js +124 -0
  62. package/dist/core/engine/tool-bridge.js +972 -33
  63. package/dist/core/feedback/queue.js +177 -0
  64. package/dist/core/feedback/submitter.js +145 -0
  65. package/dist/core/file-cache.js +113 -1
  66. package/dist/core/hooks/events.js +44 -0
  67. package/dist/core/hooks/index.js +15 -0
  68. package/dist/core/hooks/registry.js +213 -0
  69. package/dist/core/hooks/runner.js +236 -0
  70. package/dist/core/init/scaffold.js +195 -0
  71. package/dist/core/lsp/cache.js +105 -0
  72. package/dist/core/lsp/client.js +174 -29
  73. package/dist/core/lsp/language-detect.js +66 -0
  74. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  75. package/dist/core/mcp/client.js +75 -6
  76. package/dist/core/mcp/http-server.js +553 -0
  77. package/dist/core/mcp/permission.js +190 -0
  78. package/dist/core/mcp/registry.js +24 -2
  79. package/dist/core/mcp/server-tools.js +219 -0
  80. package/dist/core/mcp/server.js +397 -0
  81. package/dist/core/memory/dual-write.js +416 -0
  82. package/dist/core/memory/dual-write.spec.js +297 -0
  83. package/dist/core/memory/phase1-kinds.js +20 -0
  84. package/dist/core/memory-sync/queue.js +158 -0
  85. package/dist/core/memory-sync/queue.spec.js +105 -0
  86. package/dist/core/onboarding/marker.js +111 -0
  87. package/dist/core/onboarding/telemetry-state.js +108 -0
  88. package/dist/core/output-style/presets.js +176 -0
  89. package/dist/core/output-style/state.js +185 -0
  90. package/dist/core/permissions/gate.js +187 -0
  91. package/dist/core/permissions/index.js +18 -0
  92. package/dist/core/permissions/mode.js +102 -0
  93. package/dist/core/permissions/state.js +215 -0
  94. package/dist/core/permissions/tool-class.js +93 -0
  95. package/dist/core/prd-check/parser.js +215 -0
  96. package/dist/core/prd-check/reporter.js +127 -0
  97. package/dist/core/prd-check/session-review.js +557 -0
  98. package/dist/core/prd-check/verifiers.js +223 -0
  99. package/dist/core/pugi-md/context-injector.js +76 -0
  100. package/dist/core/pugi-md/walk-up.js +207 -0
  101. package/dist/core/release-notes/parser.js +241 -0
  102. package/dist/core/release-notes/state.js +116 -0
  103. package/dist/core/repl/codebase-survey.js +308 -0
  104. package/dist/core/repl/history.js +11 -1
  105. package/dist/core/repl/init-interview.js +457 -0
  106. package/dist/core/repl/model-pricing.js +135 -0
  107. package/dist/core/repl/onboarding-state.js +297 -0
  108. package/dist/core/repl/session.js +1529 -30
  109. package/dist/core/repl/slash-commands.js +361 -13
  110. package/dist/core/repl/store/session-store.js +31 -2
  111. package/dist/core/repl/workspace-context.js +22 -0
  112. package/dist/core/repo-map/build.js +125 -0
  113. package/dist/core/repo-map/cache.js +185 -0
  114. package/dist/core/repo-map/extractor.js +254 -0
  115. package/dist/core/repo-map/formatter.js +145 -0
  116. package/dist/core/repo-map/scanner.js +211 -0
  117. package/dist/core/retry-budget/budget.js +284 -0
  118. package/dist/core/retry-budget/index.js +5 -0
  119. package/dist/core/session.js +44 -0
  120. package/dist/core/settings.js +80 -0
  121. package/dist/core/share/formatter.js +271 -0
  122. package/dist/core/share/redactor.js +221 -0
  123. package/dist/core/share/uploader.js +267 -0
  124. package/dist/core/skills/defaults.js +457 -0
  125. package/dist/core/subagents/dispatcher-real.js +600 -0
  126. package/dist/core/subagents/dispatcher.js +113 -24
  127. package/dist/core/subagents/index.js +18 -5
  128. package/dist/core/subagents/isolation-matrix.js +213 -0
  129. package/dist/core/subagents/spawn.js +19 -4
  130. package/dist/core/telemetry/emitter.js +229 -0
  131. package/dist/core/telemetry/queue.js +251 -0
  132. package/dist/core/theme/context.js +91 -0
  133. package/dist/core/theme/presets.js +228 -0
  134. package/dist/core/theme/state.js +181 -0
  135. package/dist/core/todos/invariant.js +10 -0
  136. package/dist/core/todos/state.js +177 -0
  137. package/dist/core/transport/version-interceptor.js +166 -0
  138. package/dist/core/vim/keymap.js +288 -0
  139. package/dist/core/vim/state.js +92 -0
  140. package/dist/index.js +28 -0
  141. package/dist/runtime/bootstrap.js +190 -0
  142. package/dist/runtime/cli.js +2603 -278
  143. package/dist/runtime/commands/chain.js +489 -0
  144. package/dist/runtime/commands/compact.js +297 -0
  145. package/dist/runtime/commands/cost.js +199 -0
  146. package/dist/runtime/commands/delegate.js +312 -0
  147. package/dist/runtime/commands/dispatch.js +126 -0
  148. package/dist/runtime/commands/doctor.js +390 -0
  149. package/dist/runtime/commands/feedback.js +184 -0
  150. package/dist/runtime/commands/hooks.js +184 -0
  151. package/dist/runtime/commands/lsp.js +212 -28
  152. package/dist/runtime/commands/mcp.js +824 -0
  153. package/dist/runtime/commands/memory.js +508 -0
  154. package/dist/runtime/commands/memory.spec.js +174 -0
  155. package/dist/runtime/commands/model.js +237 -0
  156. package/dist/runtime/commands/onboarding.js +275 -0
  157. package/dist/runtime/commands/patch.js +17 -0
  158. package/dist/runtime/commands/permissions.js +87 -0
  159. package/dist/runtime/commands/plan.js +143 -0
  160. package/dist/runtime/commands/prd-check.js +285 -0
  161. package/dist/runtime/commands/release-notes.js +229 -0
  162. package/dist/runtime/commands/repo-map.js +95 -0
  163. package/dist/runtime/commands/report.js +299 -0
  164. package/dist/runtime/commands/resume.js +118 -0
  165. package/dist/runtime/commands/review-consensus.js +17 -2
  166. package/dist/runtime/commands/rewind.js +333 -0
  167. package/dist/runtime/commands/roster.js +117 -0
  168. package/dist/runtime/commands/sessions.js +163 -0
  169. package/dist/runtime/commands/share.js +316 -0
  170. package/dist/runtime/commands/status.js +178 -0
  171. package/dist/runtime/commands/stickers.js +82 -0
  172. package/dist/runtime/commands/style.js +194 -0
  173. package/dist/runtime/commands/theme.js +196 -0
  174. package/dist/runtime/commands/update.js +289 -0
  175. package/dist/runtime/commands/vim.js +140 -0
  176. package/dist/runtime/commands/worktree.js +50 -6
  177. package/dist/runtime/headless.js +543 -0
  178. package/dist/runtime/load-hooks-or-exit.js +71 -0
  179. package/dist/runtime/plan-decompose.js +531 -0
  180. package/dist/runtime/version.js +65 -0
  181. package/dist/tools/agent-tool.js +229 -0
  182. package/dist/tools/apply-patch.js +281 -39
  183. package/dist/tools/ask-user-question.js +213 -0
  184. package/dist/tools/ask-user.js +115 -0
  185. package/dist/tools/file-tools.js +85 -14
  186. package/dist/tools/mcp-tool.js +260 -0
  187. package/dist/tools/multi-edit.js +361 -0
  188. package/dist/tools/registry.js +30 -2
  189. package/dist/tools/skill-tool.js +96 -0
  190. package/dist/tools/tasks.js +208 -0
  191. package/dist/tools/todo-write.js +184 -0
  192. package/dist/tools/web-fetch.js +147 -2
  193. package/dist/tools/web-search.js +458 -0
  194. package/dist/tui/agent-progress-card.js +111 -0
  195. package/dist/tui/agent-tree.js +10 -0
  196. package/dist/tui/ask-modal.js +2 -2
  197. package/dist/tui/ask-user-question-prompt.js +192 -0
  198. package/dist/tui/compact-banner.js +81 -0
  199. package/dist/tui/conversation-pane.js +82 -8
  200. package/dist/tui/cost-table.js +111 -0
  201. package/dist/tui/doctor-table.js +46 -0
  202. package/dist/tui/feedback-prompt.js +156 -0
  203. package/dist/tui/input-box.js +46 -2
  204. package/dist/tui/markdown-render.js +4 -4
  205. package/dist/tui/onboarding-wizard.js +240 -0
  206. package/dist/tui/repl-render.js +293 -35
  207. package/dist/tui/repl-splash.js +2 -2
  208. package/dist/tui/repl.js +45 -13
  209. package/dist/tui/splash.js +1 -1
  210. package/dist/tui/status-bar.js +94 -16
  211. package/dist/tui/status-table.js +7 -0
  212. package/dist/tui/stickers-art.js +136 -0
  213. package/dist/tui/style-table.js +28 -0
  214. package/dist/tui/theme-table.js +29 -0
  215. package/dist/tui/tool-stream-pane.js +7 -0
  216. package/dist/tui/update-banner.js +20 -2
  217. package/dist/tui/vim-input.js +267 -0
  218. package/docs/examples/codegraph.mcp.json +10 -0
  219. package/package.json +9 -6
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Per-process LSP client cache — Leak L15.
3
+ *
4
+ * The α7.7 `runtime/commands/lsp.ts` CLI surface spawns one LSP server
5
+ * per invocation and stops it at the end. That is correct for the
6
+ * one-shot `pugi lsp hover ...` shape but wrong for L15's
7
+ * post-edit auto-diagnostics: every successful `edit`/`write` would
8
+ * otherwise pay the ~2-3s cold-start of `typescript-language-server`,
9
+ * which is unusable inside an agent loop.
10
+ *
11
+ * This module owns a singleton map keyed by `LspLanguage` with lazy
12
+ * initialization (`getOrStart`). The first edit of a TS file in a
13
+ * session pays cold-start; every subsequent edit of any TS/TSX file
14
+ * in the same workspace reuses the warm client.
15
+ *
16
+ * Lifecycle:
17
+ * - `getOrStart(lang, cwd)` — spawn if missing, return cached otherwise.
18
+ * - `stopAll()` — graceful shutdown of every cached client. Called from
19
+ * `runCli` exit so a Ctrl-C never leaves zombie LSP processes behind.
20
+ * - `reset()` — test-only escape hatch, drops the cache without
21
+ * touching child processes (specs inject stubs that own their own
22
+ * lifecycle).
23
+ *
24
+ * Failure handling: a startup failure is NOT cached. The next call
25
+ * tries again. This keeps the cache from poisoning a session when the
26
+ * operator installs the missing LSP binary mid-session and re-edits.
27
+ *
28
+ * Brand voice: ASCII only, no emoji, no banned words.
29
+ */
30
+ import { isLspLanguageDisabled, startLspClient, } from './client.js';
31
+ const cache = new Map();
32
+ /**
33
+ * Return a warm client for `lang`, starting one if needed. The
34
+ * workspace `cwd` is captured at cache-insert time; if a subsequent
35
+ * call asks for the same language with a different `cwd` we tear
36
+ * down the old client and start a fresh one. This handles the
37
+ * agent-worktree case where the same process hops between workspace
38
+ * roots inside one Node lifetime.
39
+ */
40
+ export async function getOrStartLspClient(lang, opts) {
41
+ // β7 L9: respect the per-language disable toggle BEFORE we attempt to
42
+ // spawn. The check is cheap and keeps the disabled path from paying
43
+ // the `npx --yes` warmup cost on first use.
44
+ if (isLspLanguageDisabled(lang, opts.lspSettings)) {
45
+ return {
46
+ ok: false,
47
+ reason: 'lsp_disabled',
48
+ detail: `${lang} is disabled via .pugi/settings.json::lsp`,
49
+ };
50
+ }
51
+ const existing = cache.get(lang);
52
+ if (existing && existing.cwd === opts.cwd) {
53
+ return { ok: true, client: existing.client };
54
+ }
55
+ if (existing && existing.cwd !== opts.cwd) {
56
+ // Workspace switched — stop the old client and fall through to spawn.
57
+ try {
58
+ await existing.client.stop();
59
+ }
60
+ catch {
61
+ // best effort; stop() is idempotent + swallow-safe
62
+ }
63
+ cache.delete(lang);
64
+ }
65
+ const result = await startLspClient(lang, opts);
66
+ if (!result.ok) {
67
+ return { ok: false, reason: result.reason, detail: result.detail };
68
+ }
69
+ cache.set(lang, { client: result.value, cwd: opts.cwd });
70
+ return { ok: true, client: result.value };
71
+ }
72
+ /** Look up the cached client without starting one. Returns undefined when missing. */
73
+ export function peekLspClient(lang) {
74
+ return cache.get(lang)?.client;
75
+ }
76
+ /** Snapshot of currently-cached languages — used by `pugi lsp status` debug output. */
77
+ export function listCachedLanguages() {
78
+ return Array.from(cache.keys());
79
+ }
80
+ /**
81
+ * Stop every cached client and clear the cache. Called from `runCli`
82
+ * exit and from specs that own the lifecycle of their stub servers.
83
+ */
84
+ export async function stopAllLspClients() {
85
+ const snapshot = Array.from(cache.values());
86
+ cache.clear();
87
+ await Promise.all(snapshot.map(async (entry) => {
88
+ try {
89
+ await entry.client.stop();
90
+ }
91
+ catch {
92
+ // best effort — shutting down anyway
93
+ }
94
+ }));
95
+ }
96
+ /**
97
+ * Test-only: drop the cache map WITHOUT calling stop on the children.
98
+ * Specs that inject stub servers manage the stub lifecycle themselves;
99
+ * this lets a spec swap a stub mid-test without the cache holding a
100
+ * stale reference to a torn-down process.
101
+ */
102
+ export function __resetLspCacheForTests() {
103
+ cache.clear();
104
+ }
105
+ //# sourceMappingURL=cache.js.map
@@ -62,7 +62,7 @@
62
62
  */
63
63
  import { spawn, spawnSync } from 'node:child_process';
64
64
  import { pathToFileURL } from 'node:url';
65
- import { readFileSync } from 'node:fs';
65
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
66
66
  import { resolve, sep } from 'node:path';
67
67
  import { OperatorAbortedError } from '../../tools/file-tools.js';
68
68
  const LANGUAGE_SERVERS = {
@@ -130,6 +130,13 @@ export class LspClient {
130
130
  child.stderr.on('data', () => { });
131
131
  }
132
132
  child.on('exit', () => this.onExit());
133
+ // R1 fix (2026-05-26, PR #413 r1, P2 #11): mirror onExit for the
134
+ // 'error' event. A late-fired spawn error (EIO, ENOMEM, etc.) or
135
+ // any unhandled child-process error would otherwise leave
136
+ // in-flight pending requests dangling until their per-request
137
+ // timer fired, which can be up to `requestTimeoutMs` later.
138
+ // Failing fast here matches the exit-time semantics.
139
+ child.on('error', () => this.onExit());
133
140
  }
134
141
  /**
135
142
  * Send `shutdown` + `exit`, then SIGKILL after a 1s grace window so
@@ -253,6 +260,35 @@ export class LspClient {
253
260
  detail: error instanceof Error ? error.message : String(error),
254
261
  };
255
262
  }
263
+ // R1 fix (2026-05-26, PR #413 r1, Fix 8): realpath containment.
264
+ // Without this gate, a workspace-local symlink (e.g. `alias` ->
265
+ // `/etc/passwd`) passed the lexical `absPath.startsWith(cwd)`
266
+ // check, then `readFileSync(absPath, 'utf8')` happily followed the
267
+ // symlink and shipped `/etc/passwd` into the LSP `textDocument/didOpen`
268
+ // payload. Parity with `applySecurityGate`'s symlink-escape rule:
269
+ // when the file exists, the realpath MUST stay inside the workspace
270
+ // realpath. Missing files (newly-typed paths the operator is
271
+ // querying) skip the check — there's no symlink target to escape.
272
+ if (existsSync(absPath)) {
273
+ try {
274
+ const realRoot = realpathSync.native(this.cwd);
275
+ const realTarget = realpathSync.native(absPath);
276
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
277
+ return {
278
+ ok: false,
279
+ reason: 'lsp_error',
280
+ detail: `symlink escapes workspace: ${file} -> ${realTarget}`,
281
+ };
282
+ }
283
+ }
284
+ catch (error) {
285
+ return {
286
+ ok: false,
287
+ reason: 'lsp_error',
288
+ detail: `cannot realpath ${file}: ${error instanceof Error ? error.message : String(error)}`,
289
+ };
290
+ }
291
+ }
256
292
  const uri = pathToFileURL(absPath).toString();
257
293
  if (!this.openedFiles.has(uri)) {
258
294
  try {
@@ -349,8 +385,22 @@ export class LspClient {
349
385
  const headerText = this.buffer.subarray(0, headerEnd).toString('ascii');
350
386
  const lengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
351
387
  if (!lengthMatch || lengthMatch[1] === undefined) {
352
- // Malformed header drop the buffer and resync at the next message.
353
- this.buffer = Buffer.alloc(0);
388
+ // R1 fix (2026-05-26, PR #413 r1, Fix 7): malformed header
389
+ // instead of nuking the entire buffer (which would discard ANY
390
+ // subsequent valid messages already queued in `this.buffer`),
391
+ // scan forward for the next `Content-Length:` marker and resync
392
+ // from there. A misbehaving server that emits one bad header
393
+ // followed by a normal stream of responses must not freeze the
394
+ // client. When no recoverable next marker is in the buffer, we
395
+ // keep the buffer as-is and wait for more data — the broken
396
+ // bytes will be re-evaluated on the next chunk.
397
+ const nextHeaderIdx = this.buffer.indexOf(Buffer.from('Content-Length:'), 1);
398
+ if (nextHeaderIdx > 0) {
399
+ this.buffer = this.buffer.subarray(nextHeaderIdx);
400
+ continue;
401
+ }
402
+ // No next marker visible — wait for more data, do not nuke the
403
+ // buffer. A subsequent chunk may complete a valid header.
354
404
  return;
355
405
  }
356
406
  const length = Number.parseInt(lengthMatch[1], 10);
@@ -415,14 +465,59 @@ export class LspClient {
415
465
  }
416
466
  }
417
467
  }
468
+ /**
469
+ * Map a short LSP language slug to the settings.json key. β7 L9 — the
470
+ * settings schema spells out the full language name (`typescript`,
471
+ * `python`, ...) for human readability; the short slug (`ts`, `py`) is
472
+ * what every internal call site uses. Keep this map narrow and explicit.
473
+ */
474
+ const SETTINGS_KEY_BY_LANG = {
475
+ ts: 'typescript',
476
+ js: 'javascript',
477
+ py: 'python',
478
+ go: 'go',
479
+ rust: 'rust',
480
+ };
481
+ /**
482
+ * Report whether the operator has explicitly disabled this language via
483
+ * `.pugi/settings.json::lsp.<language> = false`. Absent section or
484
+ * absent key means "enabled by default" — backwards-compatible with the
485
+ * α7.7 surface that ignored settings entirely. Returns true ONLY when
486
+ * the operator explicitly set the value to false.
487
+ */
488
+ export function isLspLanguageDisabled(lang, lspSettings) {
489
+ if (!lspSettings)
490
+ return false;
491
+ const key = SETTINGS_KEY_BY_LANG[lang];
492
+ return lspSettings[key] === false;
493
+ }
494
+ /**
495
+ * Probe every registered language server. Operator-facing helper for
496
+ * `pugi lsp servers` — returns one row per language with the binary
497
+ * name, whether it was found on PATH, and whether the settings toggle
498
+ * has explicitly disabled it.
499
+ */
500
+ export function inspectLspServers(lspSettings) {
501
+ const out = [];
502
+ for (const lang of Object.keys(LANGUAGE_SERVERS)) {
503
+ const server = LANGUAGE_SERVERS[lang];
504
+ out.push({
505
+ language: lang,
506
+ command: server.command + (server.args.length > 0 ? ` ${server.args.join(' ')}` : ''),
507
+ available: detectBinary(server.probe),
508
+ enabled: !isLspLanguageDisabled(lang, lspSettings),
509
+ });
510
+ }
511
+ return out;
512
+ }
418
513
  /**
419
514
  * Start an LSP client for the given language. Returns either an `LspClient`
420
515
  * ready to use, or a structured failure (`lsp_unavailable`,
421
516
  * `language_unsupported`).
422
517
  *
423
- * The returned client requires `await client.initialize()` BEFORE any
424
- * operation. We do not inline the initialize into the spawn so test
425
- * harnesses can intercept the handshake and assert on its contents.
518
+ * β7 L9: respects `.pugi/settings.json::lsp.<language> = false`
519
+ * a disabled language reports `lsp_disabled` so the caller surface can
520
+ * tell the operator the binary IS available but settings says no.
426
521
  */
427
522
  export async function startLspClient(lang, opts) {
428
523
  const server = opts.serverOverride ?? LANGUAGE_SERVERS[lang];
@@ -433,6 +528,14 @@ export async function startLspClient(lang, opts) {
433
528
  detail: `no LSP server registered for language: ${lang}`,
434
529
  };
435
530
  }
531
+ if (!opts.serverOverride && isLspLanguageDisabled(lang, opts.lspSettings)) {
532
+ return {
533
+ ok: false,
534
+ reason: 'lsp_disabled',
535
+ detail: `${lang} is disabled in .pugi/settings.json::lsp.${SETTINGS_KEY_BY_LANG[lang]}. ` +
536
+ `Remove the override (or set it to true) to enable.`,
537
+ };
538
+ }
436
539
  if (!opts.serverOverride) {
437
540
  const available = detectBinary(server.probe);
438
541
  if (!available) {
@@ -459,12 +562,51 @@ export async function startLspClient(lang, opts) {
459
562
  detail: `failed to spawn ${server.command}: ${error instanceof Error ? error.message : String(error)}`,
460
563
  };
461
564
  }
565
+ // `child_process.spawn` reports a missing binary asynchronously via
566
+ // the 'error' event, NOT via a synchronous throw — the synchronous
567
+ // spawn returns a ChildProcess object even when the binary does not
568
+ // exist. Attach an error listener immediately so the missing-binary
569
+ // case never becomes an uncaught exception. Wait one microtask tick
570
+ // for the event-loop to fire the 'error' event before we attempt
571
+ // the handshake; if the spawn failed, return early with
572
+ // `lsp_unavailable`.
573
+ let spawnError = null;
574
+ child.on('error', (err) => {
575
+ spawnError = err;
576
+ });
577
+ // Yield one tick so Node's spawn-error path lands before we
578
+ // proceed. The error event lives on the same nextTick queue as the
579
+ // initial spawn handshake, so a single setImmediate-equivalent
580
+ // delay is enough to observe it.
581
+ await new Promise((resolveFn) => {
582
+ setImmediate(resolveFn);
583
+ });
584
+ if (spawnError) {
585
+ try {
586
+ child.kill('SIGKILL');
587
+ }
588
+ catch {
589
+ // ignore — process never started
590
+ }
591
+ return {
592
+ ok: false,
593
+ reason: 'lsp_unavailable',
594
+ detail: `failed to spawn ${server.command}: ${spawnError.message}`,
595
+ };
596
+ }
462
597
  const client = new LspClient(child, server, opts);
463
598
  try {
464
599
  await initializeHandshake(client, opts.cwd);
465
600
  }
466
601
  catch (error) {
467
602
  await client.stop();
603
+ if (spawnError) {
604
+ return {
605
+ ok: false,
606
+ reason: 'lsp_unavailable',
607
+ detail: `failed to spawn ${server.command}: ${spawnError.message}`,
608
+ };
609
+ }
468
610
  return {
469
611
  ok: false,
470
612
  reason: 'lsp_error',
@@ -475,10 +617,11 @@ export async function startLspClient(lang, opts) {
475
617
  }
476
618
  async function initializeHandshake(client, cwd) {
477
619
  const rootUri = pathToFileURL(cwd).toString();
478
- // Use the public sendRequest path via a method call. We piggyback on
479
- // `LspClient`'s internal `sendRequest` by exposing a single test-grade
480
- // surface (`__lspRaw__`); production callers never need this.
481
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
620
+ // Reach the private send-request / send-notification surface through
621
+ // a typed accessor cast. The two methods are intentionally not part
622
+ // of the public class surface (callers should use `hover`/`definition`
623
+ // etc.), but the handshake is a single-shot bootstrap and exposing
624
+ // the raw methods would weaken the type story.
482
625
  const internal = client;
483
626
  await internal.sendRequest('initialize', {
484
627
  processId: process.pid,
@@ -520,27 +663,29 @@ function normalizeHover(raw) {
520
663
  const obj = raw;
521
664
  const range = parseRange(obj.range);
522
665
  const body = obj.contents;
523
- if (typeof body === 'string') {
524
- return { content: body, range, raw };
525
- }
526
- if (Array.isArray(body)) {
527
- const parts = [];
528
- for (const item of body) {
529
- if (typeof item === 'string')
530
- parts.push(item);
531
- else if (item && typeof item === 'object' && 'value' in item) {
532
- const value = item.value;
533
- if (typeof value === 'string')
534
- parts.push(value);
666
+ const result = (() => {
667
+ if (typeof body === 'string')
668
+ return { content: body, raw, ...(range ? { range } : {}) };
669
+ if (Array.isArray(body)) {
670
+ const parts = [];
671
+ for (const item of body) {
672
+ if (typeof item === 'string')
673
+ parts.push(item);
674
+ else if (item && typeof item === 'object' && 'value' in item) {
675
+ const value = item.value;
676
+ if (typeof value === 'string')
677
+ parts.push(value);
678
+ }
535
679
  }
680
+ return { content: parts.join('\n'), raw, ...(range ? { range } : {}) };
536
681
  }
537
- return { content: parts.join('\n'), range, raw };
538
- }
539
- if (body && typeof body === 'object' && 'value' in body) {
540
- const value = body.value;
541
- return { content: typeof value === 'string' ? value : '', range, raw };
542
- }
543
- return { content: '', range, raw };
682
+ if (body && typeof body === 'object' && 'value' in body) {
683
+ const value = body.value;
684
+ return { content: typeof value === 'string' ? value : '', raw, ...(range ? { range } : {}) };
685
+ }
686
+ return { content: '', raw, ...(range ? { range } : {}) };
687
+ })();
688
+ return result;
544
689
  }
545
690
  function normalizeLocations(raw, cwd) {
546
691
  if (!raw)
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Language-from-extension detection — Leak L15.
3
+ *
4
+ * Single source of truth for "given a file path, which `LspLanguage`
5
+ * slug do we route to". The α7.7 `runtime/commands/lsp.ts` shipped its
6
+ * own inline `inferLanguage` switch; L15 (post-edit auto-diagnostics)
7
+ * needs the same lookup from `core/engine/tool-bridge.ts`, so we lift
8
+ * the table into a dedicated module to avoid a second copy drifting
9
+ * out of sync.
10
+ *
11
+ * Returning `undefined` is the calling code's signal to silently skip
12
+ * LSP — an unsupported extension is NOT an error, it just means "no
13
+ * diagnostics for this file". The tool-bridge hook treats this as a
14
+ * no-op envelope tail.
15
+ *
16
+ * Adding a new language requires THREE coordinated changes:
17
+ * 1. Add the `LspLanguage` slug + server descriptor in `client.ts`.
18
+ * 2. Map its extensions here.
19
+ * 3. Add a `lsp-language-matrix` spec row exercising the new ext.
20
+ *
21
+ * Brand voice: ASCII only, no emoji, no banned words.
22
+ */
23
+ import { extname } from 'node:path';
24
+ /**
25
+ * Lower-case extension (including the dot) → LSP language slug.
26
+ * Mirror of the switch in `runtime/commands/lsp.ts::inferLanguage`.
27
+ * The table form lets tests assert coverage and lets new languages
28
+ * land with one edit instead of two.
29
+ */
30
+ export const EXTENSION_TO_LANGUAGE = {
31
+ '.ts': 'ts',
32
+ '.tsx': 'ts',
33
+ '.mts': 'ts',
34
+ '.cts': 'ts',
35
+ '.js': 'js',
36
+ '.jsx': 'js',
37
+ '.mjs': 'js',
38
+ '.cjs': 'js',
39
+ '.py': 'py',
40
+ '.pyi': 'py',
41
+ '.go': 'go',
42
+ '.rs': 'rust',
43
+ };
44
+ /**
45
+ * Infer the `LspLanguage` for a workspace-relative or absolute path.
46
+ * Returns `undefined` for unmapped extensions — the caller decides
47
+ * whether that is silently skipped (post-edit hook) or surfaced as
48
+ * `language_unsupported` (`pugi lsp` CLI).
49
+ */
50
+ export function languageForFile(file) {
51
+ const ext = extname(file).toLowerCase();
52
+ if (!ext)
53
+ return undefined;
54
+ return EXTENSION_TO_LANGUAGE[ext];
55
+ }
56
+ /**
57
+ * Return every extension currently mapped to the given language.
58
+ * Used by the matrix spec to assert coverage without re-typing the
59
+ * extension list.
60
+ */
61
+ export function extensionsForLanguage(lang) {
62
+ return Object.entries(EXTENSION_TO_LANGUAGE)
63
+ .filter(([, value]) => value === lang)
64
+ .map(([ext]) => ext);
65
+ }
66
+ //# sourceMappingURL=language-detect.js.map
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Post-edit diagnostics — Leak L15.
3
+ *
4
+ * Claude Code's leak intel surfaced this pattern: after a `FileEdit` /
5
+ * `Write` tool call lands, an LSP diagnostic pass runs on the touched
6
+ * file and the result is appended to the tool envelope before the
7
+ * model sees it. The model then self-corrects in the same turn —
8
+ * "TS2304: Cannot find name 'undef'" comes back, the model fixes the
9
+ * typo in the next tool call, no operator round-trip needed.
10
+ *
11
+ * This module is the Pugi side of that pattern:
12
+ *
13
+ * 1. The tool-bridge calls `runPostEditDiagnostics(path, ctx)` after
14
+ * a successful `edit` / `write` / `multi_edit`.
15
+ * 2. We infer the language from the extension (`language-detect`).
16
+ * Unsupported extension → `{ skip: true }` and the bridge appends
17
+ * nothing.
18
+ * 3. We borrow (or lazily start) the per-language cached client
19
+ * from `cache.ts`. A spawn failure → `{ skip: true }` and the
20
+ * envelope stays clean. Silence on failure is intentional: an
21
+ * operator who has not installed `typescript-language-server`
22
+ * should not see an LSP nag on every edit.
23
+ * 4. We pull diagnostics with a hard 5s ceiling. A timeout logs a
24
+ * warning on stderr (gated on `PUGI_LSP_DEBUG=1`) and skips —
25
+ * the envelope is never blocked on LSP.
26
+ * 5. We format the surviving diagnostics into a readable tail
27
+ * mirroring the leak format:
28
+ *
29
+ * LSP DIAGNOSTICS (typescript):
30
+ * foo.ts:42:5 error TS2304: Cannot find name 'undef'.
31
+ * foo.ts:51:1 warn TS6133: 'unused' is declared.
32
+ *
33
+ * The bridge concatenates this tail onto its existing `wrote ...` /
34
+ * `edited ...` body with a single newline separator. When there are
35
+ * zero diagnostics we return `{ skip: true }` so the existing body
36
+ * is unchanged — the "no news is good news" path stays terse.
37
+ *
38
+ * Brand voice: ASCII only, no emoji, no banned words.
39
+ */
40
+ import { isAbsolute, relative, resolve } from 'node:path';
41
+ import { getOrStartLspClient } from './cache.js';
42
+ import { languageForFile } from './language-detect.js';
43
+ const DEFAULT_TIMEOUT_MS = 5_000;
44
+ /**
45
+ * Hard cap on how many diagnostics we surface to the model. A file
46
+ * with 200 errors after a broken bulk edit would otherwise blow the
47
+ * context window; the model can re-run `pugi lsp diagnostics` if
48
+ * it needs the full list.
49
+ */
50
+ const MAX_DIAGNOSTICS = 25;
51
+ export async function runPostEditDiagnostics(filePath, opts) {
52
+ const lang = languageForFile(filePath);
53
+ if (!lang) {
54
+ return { skip: true, reason: 'unsupported_language' };
55
+ }
56
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
57
+ const clientResult = await loadClient(lang, opts);
58
+ if (!clientResult.ok) {
59
+ return { skip: true, reason: mapStartFailure(clientResult.reason) };
60
+ }
61
+ // Run diagnostics with a hard timeout. The underlying LspClient has
62
+ // its own per-request timeout (5s default) but a slow handshake
63
+ // can blow past it; we belt-and-suspenders here so the agent loop
64
+ // never blocks on LSP.
65
+ const relPath = toWorkspaceRelative(filePath, opts.cwd);
66
+ const diagnosticsPromise = clientResult.client.diagnostics(relPath);
67
+ let timer;
68
+ const timeoutPromise = new Promise((resolveFn) => {
69
+ timer = setTimeout(() => resolveFn({ timedOut: true }), timeoutMs);
70
+ timer.unref();
71
+ });
72
+ const race = await Promise.race([
73
+ diagnosticsPromise.then((value) => ({ timedOut: false, value })),
74
+ timeoutPromise,
75
+ ]);
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ if (race.timedOut) {
79
+ const writeFn = opts.debugWrite ?? ((line) => {
80
+ if (process.env.PUGI_LSP_DEBUG === '1')
81
+ process.stderr.write(`${line}\n`);
82
+ });
83
+ writeFn(`[pugi-lsp] post-edit diagnostics for ${relPath} timed out after ${timeoutMs}ms (lang=${lang})`);
84
+ return { skip: true, reason: 'timeout' };
85
+ }
86
+ const diag = race.value;
87
+ if (!diag.ok) {
88
+ return { skip: true, reason: 'lsp_error' };
89
+ }
90
+ if (diag.value.length === 0) {
91
+ return { skip: true, reason: 'no_diagnostics' };
92
+ }
93
+ const tail = formatDiagnosticsTail(relPath, lang, diag.value);
94
+ return { skip: false, tail, count: diag.value.length, language: lang };
95
+ }
96
+ async function loadClient(lang, opts) {
97
+ if (opts.clientLoader) {
98
+ return opts.clientLoader(lang);
99
+ }
100
+ const { cwd, timeoutMs, clientLoader: _ignoredA, debugWrite: _ignoredB, ...rest } = opts;
101
+ const result = await getOrStartLspClient(lang, { cwd, ...rest });
102
+ if (!result.ok) {
103
+ return { ok: false, reason: result.reason, detail: result.detail };
104
+ }
105
+ return { ok: true, client: result.client };
106
+ }
107
+ function mapStartFailure(reason) {
108
+ if (reason === 'lsp_unavailable' || reason === 'language_unsupported')
109
+ return 'lsp_unavailable';
110
+ if (reason === 'lsp_disabled')
111
+ return 'lsp_disabled';
112
+ return 'lsp_error';
113
+ }
114
+ /**
115
+ * Convert an absolute or workspace-relative path into the form the
116
+ * LSP client expects — same shape as `runtime/commands/lsp.ts` uses.
117
+ */
118
+ function toWorkspaceRelative(filePath, cwd) {
119
+ if (!isAbsolute(filePath))
120
+ return filePath;
121
+ const rel = relative(cwd, resolve(cwd, filePath));
122
+ return rel || filePath;
123
+ }
124
+ /**
125
+ * Format diagnostics into the leak-shaped envelope tail. Pure function
126
+ * exported for unit tests to assert the line format independent of
127
+ * any LSP plumbing.
128
+ */
129
+ export function formatDiagnosticsTail(relPath, lang, diagnostics) {
130
+ const visible = diagnostics.slice(0, MAX_DIAGNOSTICS);
131
+ const truncated = diagnostics.length > visible.length;
132
+ const lines = [`LSP DIAGNOSTICS (${LANGUAGE_LABELS[lang]}):`];
133
+ for (const diag of visible) {
134
+ const line = diag.range.start.line + 1; // LSP is zero-based; humans expect 1-based.
135
+ const col = diag.range.start.character + 1;
136
+ const severity = SEVERITY_LABELS[diag.severityLabel];
137
+ const code = diag.code !== undefined && diag.code !== '' ? ` ${diag.code}` : '';
138
+ const source = diag.source ? `${diag.source}` : '';
139
+ const head = source ? `${severity}${code} (${source}):` : `${severity}${code}:`;
140
+ lines.push(` ${relPath}:${line}:${col} ${head} ${diag.message}`);
141
+ }
142
+ if (truncated) {
143
+ lines.push(` ... ${diagnostics.length - visible.length} more diagnostic(s) — re-run pugi lsp diagnostics ${relPath} for the full list`);
144
+ }
145
+ return lines.join('\n');
146
+ }
147
+ const LANGUAGE_LABELS = {
148
+ ts: 'typescript',
149
+ js: 'javascript',
150
+ py: 'python',
151
+ go: 'go',
152
+ rust: 'rust',
153
+ };
154
+ /**
155
+ * Map LSP severity label → the short token the leak envelope uses.
156
+ * "warn" is shorter than "warning" and matches Claude Code's leak
157
+ * verbatim; the rest mirror LSP terminology.
158
+ */
159
+ const SEVERITY_LABELS = {
160
+ error: 'error',
161
+ warning: 'warn ',
162
+ info: 'info ',
163
+ hint: 'hint ',
164
+ };
165
+ /** Test-only surface so specs can poke the pure helpers without LSP. */
166
+ export const __test__ = {
167
+ formatDiagnosticsTail,
168
+ MAX_DIAGNOSTICS,
169
+ DEFAULT_TIMEOUT_MS,
170
+ };
171
+ //# sourceMappingURL=post-edit-diagnostics.js.map