@pugi/cli 0.1.0-beta.88 → 0.1.0-beta.89

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 (37) hide show
  1. package/dist/core/auth/env-provider.js +1 -1
  2. package/dist/core/context/markdown-traverse.js +1 -1
  3. package/dist/core/credentials.js +1 -1
  4. package/dist/core/engine/anvil-client.js +63 -0
  5. package/dist/core/engine/native-pugi.js +1 -1
  6. package/dist/core/engine/tool-bridge.js +436 -0
  7. package/dist/core/hooks/events.js +3 -1
  8. package/dist/core/hooks/registry.js +3 -0
  9. package/dist/core/hooks/worktree-events.js +158 -0
  10. package/dist/core/lsp/client.js +453 -0
  11. package/dist/core/lsp/server-detect.js +173 -0
  12. package/dist/core/lsp/symbol-cache.js +162 -0
  13. package/dist/core/lsp/symbol-tools.js +296 -4
  14. package/dist/core/mcp/server.js +1 -1
  15. package/dist/core/repl/ask.js +1 -1
  16. package/dist/core/repl/session.js +3 -3
  17. package/dist/core/repl/slash-commands.js +1 -1
  18. package/dist/core/settings.js +26 -0
  19. package/dist/core/worktree/include-parser.js +249 -0
  20. package/dist/runtime/cli.js +108 -8
  21. package/dist/runtime/commands/agents.js +1 -1
  22. package/dist/runtime/commands/hooks.js +3 -0
  23. package/dist/runtime/commands/review-consensus.js +1 -1
  24. package/dist/runtime/version.js +1 -1
  25. package/dist/runtime/worktree-bootstrap.js +579 -0
  26. package/dist/tools/lsp-tools.js +377 -1
  27. package/dist/tools/registry.js +23 -0
  28. package/dist/tui/input-box.js +1 -1
  29. package/dist/tui/render.js +1 -1
  30. package/dist/tui/repl.js +1 -1
  31. package/dist/tui/status-bar.js +1 -1
  32. package/dist/tui/update-banner.js +1 -1
  33. package/package.json +3 -3
  34. package/test/scenarios/compact-force.scenario.txt +3 -2
  35. package/test/scenarios/identity.scenario.txt +6 -5
  36. package/test/scenarios/persona-handoff.scenario.txt +2 -1
  37. package/test/scenarios/walkback.scenario.txt +6 -6
@@ -0,0 +1,173 @@
1
+ /**
2
+ * PUGI-78 Phase 1: LSP server discovery via `~/.pugi/lsp-config.json`
3
+ * + PATH probe.
4
+ *
5
+ * The base `LANGUAGE_SERVERS` registry in `client.ts` ships sane
6
+ * defaults (TypeScript via npx, pyright/gopls/rust-analyzer assumed on
7
+ * PATH). Operators who run a non-standard layout (NixOS, asdf, custom
8
+ * monorepo binaries) override per-language commands via a small JSON
9
+ * file at `$HOME/.pugi/lsp-config.json`:
10
+ *
11
+ * ```json
12
+ * {
13
+ * "typescript": {
14
+ * "command": "/usr/local/bin/typescript-language-server",
15
+ * "args": ["--stdio"]
16
+ * },
17
+ * "python": {
18
+ * "command": "pylsp",
19
+ * "args": []
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * The file is loaded once per process; failures are non-fatal (missing
25
+ * file => empty override map, parse error => log + empty override).
26
+ * The standard registry stays the fallback. The operator-facing CLI
27
+ * surface (`pugi lsp servers`) reports both detected binaries (via
28
+ * `inspectLspServers` in client.ts) and any operator overrides loaded
29
+ * from this module.
30
+ *
31
+ * Why a JSON file (not a flag): the operator may have several distinct
32
+ * LSP layouts (e.g. one for the monorepo, one for an isolated package).
33
+ * A central config in `~/.pugi/` lets every workspace inherit the same
34
+ * defaults without re-typing flags on every `pugi` invocation; the
35
+ * workspace-local `.pugi/settings.json::lsp` toggle (already shipped
36
+ * in β7 L9) layers on top to disable specific languages per-workspace.
37
+ *
38
+ * Brand voice: ASCII only, no emoji, no banned words.
39
+ */
40
+ import { homedir } from 'node:os';
41
+ import { readFileSync, existsSync } from 'node:fs';
42
+ import { join } from 'node:path';
43
+ import { spawnSync } from 'node:child_process';
44
+ /**
45
+ * Settings file path. Computed lazily so a spec can inject `HOME` via
46
+ * `process.env.HOME = '/tmp/test'` before importing this module.
47
+ */
48
+ export function lspConfigPath() {
49
+ return join(homedir(), '.pugi', 'lsp-config.json');
50
+ }
51
+ /**
52
+ * Load the operator override map from `$HOME/.pugi/lsp-config.json`.
53
+ * Non-fatal failures: missing file -> empty map; malformed JSON ->
54
+ * empty map + stderr warning when `PUGI_LSP_DEBUG=1`. Returns the
55
+ * parsed map; any unrecognized language slug is dropped silently.
56
+ *
57
+ * Synchronous because this is a one-shot bootstrap path called from
58
+ * the CLI surface before any LSP client spawn; the file is tiny (<2 KB
59
+ * in practice) so the sync read cost is negligible.
60
+ */
61
+ export function loadOperatorOverrides(path) {
62
+ const resolved = path ?? lspConfigPath();
63
+ if (!existsSync(resolved))
64
+ return {};
65
+ let raw;
66
+ try {
67
+ raw = readFileSync(resolved, 'utf8');
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(raw);
75
+ }
76
+ catch {
77
+ if (process.env.PUGI_LSP_DEBUG === '1') {
78
+ process.stderr.write(`[pugi lsp] ignored ${resolved} - invalid JSON\n`);
79
+ }
80
+ return {};
81
+ }
82
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
83
+ return {};
84
+ const out = {};
85
+ const supported = ['ts', 'js', 'py', 'go', 'rust'];
86
+ // Accept both short slug ("ts") and long name ("typescript") on the
87
+ // operator side - long names are friendlier in a hand-edited file.
88
+ const longNameMap = {
89
+ typescript: 'ts',
90
+ javascript: 'js',
91
+ python: 'py',
92
+ go: 'go',
93
+ rust: 'rust',
94
+ ts: 'ts',
95
+ js: 'js',
96
+ py: 'py',
97
+ rs: 'rust',
98
+ };
99
+ for (const [key, value] of Object.entries(parsed)) {
100
+ const lang = longNameMap[key.toLowerCase()];
101
+ if (!lang || !supported.includes(lang))
102
+ continue;
103
+ if (!value || typeof value !== 'object' || Array.isArray(value))
104
+ continue;
105
+ const v = value;
106
+ if (typeof v.command !== 'string' || v.command.length === 0)
107
+ continue;
108
+ const args = [];
109
+ if (Array.isArray(v.args)) {
110
+ for (const a of v.args) {
111
+ if (typeof a === 'string')
112
+ args.push(a);
113
+ }
114
+ }
115
+ out[lang] = { command: v.command, args };
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * Discover every supported language's server given the registry defaults
121
+ * + the operator override map. `defaultRegistry` is injected so the
122
+ * caller (the CLI surface) can pass the live `LANGUAGE_SERVERS` from
123
+ * `client.ts` without forming a circular module dep.
124
+ */
125
+ export function detectServers(defaultRegistry, overrides = loadOperatorOverrides()) {
126
+ const out = [];
127
+ for (const lang of Object.keys(defaultRegistry)) {
128
+ const base = defaultRegistry[lang];
129
+ const override = overrides[lang];
130
+ if (override) {
131
+ out.push({
132
+ language: lang,
133
+ source: 'override',
134
+ command: override.command,
135
+ args: override.args,
136
+ available: detectBinaryOnPath(override.command),
137
+ });
138
+ }
139
+ else {
140
+ out.push({
141
+ language: lang,
142
+ source: 'default',
143
+ command: base.command,
144
+ args: base.args,
145
+ available: detectBinaryOnPath(base.probe),
146
+ });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+ /**
152
+ * Cross-platform binary detection. POSIX = `which`, Windows = `where`.
153
+ * Failures are non-fatal — we just report unavailable. We avoid
154
+ * `spawnSync(name, ['--version'])` because some servers (gopls, older
155
+ * pyright) do not honor `--version` and exit non-zero, which would
156
+ * mis-flag them as missing.
157
+ */
158
+ export function detectBinaryOnPath(name) {
159
+ const probe = process.platform === 'win32' ? 'where' : 'which';
160
+ try {
161
+ const result = spawnSync(probe, [name], { stdio: 'ignore' });
162
+ return result.status === 0;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ /** Test-only — direct access to the long-name accepting map. */
169
+ export const __test__ = {
170
+ detectBinaryOnPath,
171
+ lspConfigPath,
172
+ };
173
+ //# sourceMappingURL=server-detect.js.map
@@ -0,0 +1,162 @@
1
+ /** Default TTL for cached entries. 5 minutes matches the spec target. */
2
+ export const DEFAULT_TTL_MS = 5 * 60 * 1000;
3
+ /** Default size cap. Empirically large enough for a heavy navigate session. */
4
+ export const DEFAULT_MAX_ENTRIES = 1000;
5
+ /**
6
+ * Generic TTL cache. The unknown value type is intentional — the cache
7
+ * stores serialized result objects produced by the 13 symbol tools; we
8
+ * surface them back to the caller as the SAME unknown so the cache
9
+ * does not encode the result schema.
10
+ *
11
+ * Thread-safety: Node single-threaded, no concurrent writes possible
12
+ * inside one `pugi` invocation. The cache is NOT shared across
13
+ * spawned subagents — each subagent runs its own LspClient + cache.
14
+ */
15
+ export class SymbolCache {
16
+ store = new Map();
17
+ ttlMs;
18
+ maxEntries;
19
+ now;
20
+ metrics = { hits: 0, misses: 0, evictions: 0, size: 0 };
21
+ constructor(options) {
22
+ this.ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
23
+ this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
24
+ this.now = options?.now ?? (() => Date.now());
25
+ }
26
+ /**
27
+ * Build a canonical key from the dispatch coordinates. Workspace +
28
+ * language scopes the cache so two different repos opened in the same
29
+ * REPL session do not pollute each other.
30
+ */
31
+ static makeKey(lang, workspace, verb, args) {
32
+ // Sort keys deterministically so `{file: 'a', line: 1}` and
33
+ // `{line: 1, file: 'a'}` map to the same cache entry.
34
+ const sorted = {};
35
+ for (const key of Object.keys(args).sort()) {
36
+ sorted[key] = args[key];
37
+ }
38
+ return `${lang}::${workspace}::${verb}::${JSON.stringify(sorted)}`;
39
+ }
40
+ /**
41
+ * Look up a cached value. Returns undefined when:
42
+ *
43
+ * - the key is not in the cache
44
+ * - the entry has aged past `ttlMs` (the entry is deleted in this
45
+ * case so the eviction counter is accurate)
46
+ *
47
+ * Hit updates the entry's `lastAccessed` so the LRU policy keeps
48
+ * frequently-touched entries warm.
49
+ */
50
+ get(key) {
51
+ const entry = this.store.get(key);
52
+ if (!entry) {
53
+ this.metrics.misses++;
54
+ return undefined;
55
+ }
56
+ const now = this.now();
57
+ if (now - entry.storedAt > this.ttlMs) {
58
+ this.store.delete(key);
59
+ this.metrics.evictions++;
60
+ this.metrics.misses++;
61
+ this.metrics.size = this.store.size;
62
+ return undefined;
63
+ }
64
+ entry.lastAccessed = now;
65
+ this.metrics.hits++;
66
+ return entry.result;
67
+ }
68
+ /**
69
+ * Store a value. Evicts the LRU entry when the cache hits `maxEntries`.
70
+ * The eviction counter increments for each LRU drop AND each TTL drop
71
+ * so the operator surface can distinguish "cache too small" from "TTL
72
+ * too short".
73
+ */
74
+ set(key, value) {
75
+ const now = this.now();
76
+ if (this.store.size >= this.maxEntries && !this.store.has(key)) {
77
+ // Find the LRU entry.
78
+ let oldestKey;
79
+ let oldestAccess = Number.POSITIVE_INFINITY;
80
+ for (const [k, entry] of this.store) {
81
+ if (entry.lastAccessed < oldestAccess) {
82
+ oldestAccess = entry.lastAccessed;
83
+ oldestKey = k;
84
+ }
85
+ }
86
+ if (oldestKey !== undefined) {
87
+ this.store.delete(oldestKey);
88
+ this.metrics.evictions++;
89
+ }
90
+ }
91
+ this.store.set(key, { result: value, storedAt: now, lastAccessed: now });
92
+ this.metrics.size = this.store.size;
93
+ }
94
+ /**
95
+ * Invalidate every entry for a workspace. Called by the post-edit
96
+ * hook so a successful `edit`/`write` clears stale symbol locations.
97
+ * Returns the number of entries dropped.
98
+ */
99
+ invalidateWorkspace(workspace) {
100
+ const prefix = `${workspace}::`;
101
+ // Two prefix forms: `<lang>::<workspace>::...` — we scan the full
102
+ // store and drop entries whose key contains the workspace literal.
103
+ let dropped = 0;
104
+ for (const key of Array.from(this.store.keys())) {
105
+ // The key shape is `lang::workspace::verb::args` — split on `::`
106
+ // and check the second segment. Avoid substring match because a
107
+ // workspace path could legally contain `::` (e.g. a Windows
108
+ // network path).
109
+ const parts = key.split('::');
110
+ if (parts.length >= 2 && parts[1] === workspace) {
111
+ this.store.delete(key);
112
+ dropped++;
113
+ }
114
+ else if (parts.length >= 2 && parts[1]?.startsWith(prefix)) {
115
+ this.store.delete(key);
116
+ dropped++;
117
+ }
118
+ }
119
+ if (dropped > 0) {
120
+ this.metrics.evictions += dropped;
121
+ this.metrics.size = this.store.size;
122
+ }
123
+ return dropped;
124
+ }
125
+ /** Drop every cached entry. */
126
+ clear() {
127
+ this.metrics.evictions += this.store.size;
128
+ this.store.clear();
129
+ this.metrics.size = 0;
130
+ }
131
+ /** Snapshot of the current metrics. The returned record is a copy. */
132
+ snapshot() {
133
+ return { ...this.metrics, size: this.store.size };
134
+ }
135
+ /** Test-only: reset metrics counters without dropping entries. */
136
+ resetMetrics() {
137
+ this.metrics = { hits: 0, misses: 0, evictions: 0, size: this.store.size };
138
+ }
139
+ /** Test-only: number of entries currently stored. */
140
+ size() {
141
+ return this.store.size;
142
+ }
143
+ }
144
+ /**
145
+ * Process-global symbol cache, lazily constructed on first access. The
146
+ * CLI surface picks this up via `getGlobalSymbolCache()` so every
147
+ * subagent / tool invocation in the same process shares the same cache.
148
+ * Subagents spawned in a fresh child process get a fresh cache (by
149
+ * design — the cache is process-scoped, not user-scoped).
150
+ */
151
+ let globalCache;
152
+ export function getGlobalSymbolCache(options) {
153
+ if (!globalCache) {
154
+ globalCache = new SymbolCache(options);
155
+ }
156
+ return globalCache;
157
+ }
158
+ /** Test-only: drop the singleton so a spec starts with a fresh cache. */
159
+ export function __resetGlobalSymbolCache() {
160
+ globalCache = undefined;
161
+ }
162
+ //# sourceMappingURL=symbol-cache.js.map
@@ -67,15 +67,28 @@ export function symbolKindLabel(kind) {
67
67
  return SYMBOL_KIND_LABELS[kind] ?? 'unknown';
68
68
  }
69
69
  /**
70
- * Build a `SymbolToolsTransport` from a real `LspClient`. Production
71
- * wire-up. `documentSymbols` / `workspaceSymbols` are omitted on
72
- * purpose — the `LspClient` does not expose them, so the Phase 1
73
- * surface degrades to `[]`. Phase 2 follows.
70
+ * Build a `SymbolToolsTransport` from a real `LspClient`. PUGI-78 Phase
71
+ * 1: wires every method the client exposes (13 total). Methods the
72
+ * `LspClient` does NOT support yet stay omitted on the returned
73
+ * transport so the symbol-tools surface degrades to null / [] without
74
+ * forcing the caller to wrap.
74
75
  */
75
76
  export function transportFromLspClient(client) {
76
77
  return {
77
78
  definition: (file, pos) => client.definition(file, pos),
78
79
  references: (file, pos) => client.references(file, pos),
80
+ documentSymbols: (file) => client.documentSymbols(file),
81
+ workspaceSymbols: (query) => client.workspaceSymbols(query),
82
+ hover: (file, pos) => client.hover(file, pos),
83
+ signatureHelp: (file, pos) => client.signatureHelp(file, pos),
84
+ implementations: (file, pos) => client.implementations(file, pos),
85
+ typeDefinition: (file, pos) => client.typeDefinition(file, pos),
86
+ rename: (file, pos, newName) => client.rename(file, pos, newName),
87
+ prepareRename: (file, pos) => client.prepareRename(file, pos),
88
+ codeActions: (file, range) => client.codeActions(file, range),
89
+ formatting: (file, options) => client.formatting(file, undefined, options),
90
+ diagnostics: (file) => client.diagnostics(file),
91
+ callHierarchy: (file, pos) => client.callHierarchy(file, pos),
79
92
  };
80
93
  }
81
94
  /**
@@ -222,6 +235,284 @@ export async function findWorkspaceSymbol(transport, query) {
222
235
  }
223
236
  return out;
224
237
  }
238
+ /**
239
+ * PUGI-78 Phase 1: hover (type info + docstring). Returns null when:
240
+ *
241
+ * - `filePath` is empty / whitespace
242
+ * - `line` / `character` non-finite or negative
243
+ * - the transport does not implement `hover`
244
+ * - `ok: false` from the transport
245
+ * - the server reports no hover (cursor not on a symbol)
246
+ * - the transport throws
247
+ *
248
+ * Caps the body at 4 KB so a verbose generic type signature does not
249
+ * blow the agent's context window. The cap mirrors the `lspHover`
250
+ * tool's 8 KB cap halved — the symbol surface is closer to "give me
251
+ * one sentence" than the full hover blob.
252
+ */
253
+ export async function hoverSymbol(transport, filePath, line, character) {
254
+ if (!isNonEmptyString(filePath))
255
+ return null;
256
+ if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
257
+ return null;
258
+ if (!transport.hover)
259
+ return null;
260
+ let result;
261
+ try {
262
+ result = await transport.hover(filePath, { line, character });
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ if (!result.ok)
268
+ return null;
269
+ if (!result.value)
270
+ return null;
271
+ const HOVER_CAP_BYTES = 4 * 1024;
272
+ const raw = result.value.content;
273
+ const bytes = Buffer.byteLength(raw, 'utf8');
274
+ const truncated = bytes > HOVER_CAP_BYTES;
275
+ const content = truncated
276
+ ? Buffer.from(raw, 'utf8').subarray(0, HOVER_CAP_BYTES).toString('utf8') + '\n... [truncated]'
277
+ : raw;
278
+ return {
279
+ content,
280
+ ...(result.value.range ? { range: result.value.range } : {}),
281
+ ...(truncated ? { truncated: true } : {}),
282
+ };
283
+ }
284
+ /**
285
+ * PUGI-78 Phase 1: function signature at a call site. Returns null when
286
+ * the transport does not implement `signatureHelp` OR the server
287
+ * reports no signature OR the transport fails / throws.
288
+ */
289
+ export async function signatureAt(transport, filePath, line, character) {
290
+ if (!isNonEmptyString(filePath))
291
+ return null;
292
+ if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
293
+ return null;
294
+ if (!transport.signatureHelp)
295
+ return null;
296
+ let result;
297
+ try {
298
+ result = await transport.signatureHelp(filePath, { line, character });
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ if (!result.ok)
304
+ return null;
305
+ if (!result.value)
306
+ return null;
307
+ const out = { label: result.value.label, parameters: result.value.parameters };
308
+ if (result.value.documentation)
309
+ out.documentation = result.value.documentation;
310
+ if (typeof result.value.activeParameter === 'number')
311
+ out.activeParameter = result.value.activeParameter;
312
+ return out;
313
+ }
314
+ /**
315
+ * PUGI-78 Phase 1: implementations of an interface / abstract method.
316
+ * Returns `[]` on any failure mode (parity with `findReferences`).
317
+ */
318
+ export async function findImplementations(transport, filePath, line, character) {
319
+ if (!isNonEmptyString(filePath))
320
+ return [];
321
+ if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
322
+ return [];
323
+ if (!transport.implementations)
324
+ return [];
325
+ let result;
326
+ try {
327
+ result = await transport.implementations(filePath, { line, character });
328
+ }
329
+ catch {
330
+ return [];
331
+ }
332
+ if (!result.ok)
333
+ return [];
334
+ return collapseLocations(result.value);
335
+ }
336
+ /**
337
+ * PUGI-78 Phase 1: type-definition (vs value-definition). Returns null
338
+ * matching `findDefinition`'s contract.
339
+ */
340
+ export async function findTypeDefinition(transport, filePath, line, character) {
341
+ if (!isNonEmptyString(filePath))
342
+ return null;
343
+ if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
344
+ return null;
345
+ if (!transport.typeDefinition)
346
+ return null;
347
+ let result;
348
+ try {
349
+ result = await transport.typeDefinition(filePath, { line, character });
350
+ }
351
+ catch {
352
+ return null;
353
+ }
354
+ if (!result.ok)
355
+ return null;
356
+ const first = result.value[0];
357
+ if (!first)
358
+ return null;
359
+ const file = pickFilePath(first);
360
+ if (!isNonEmptyString(file))
361
+ return null;
362
+ return { file, line: first.range.start.line, character: first.range.start.character };
363
+ }
364
+ export async function renameSymbol(transport, filePath, line, character, newName) {
365
+ if (!isNonEmptyString(filePath))
366
+ return null;
367
+ if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
368
+ return null;
369
+ if (!isNonEmptyString(newName))
370
+ return null;
371
+ if (!transport.rename)
372
+ return null;
373
+ // Best-effort prepare-rename precheck. If the server says the cursor
374
+ // is not on a renameable token, we return null without dispatching
375
+ // the full rename — saves a wasted round-trip + avoids returning a
376
+ // misleading "0 edits" preview.
377
+ if (transport.prepareRename) {
378
+ try {
379
+ const prep = await transport.prepareRename(filePath, { line, character });
380
+ if (prep.ok && prep.value === null)
381
+ return null;
382
+ }
383
+ catch {
384
+ // Continue — some servers fail prepare-rename but still execute
385
+ // the full rename. Phase 1 honors the looser fallback.
386
+ }
387
+ }
388
+ let result;
389
+ try {
390
+ result = await transport.rename(filePath, { line, character }, newName);
391
+ }
392
+ catch {
393
+ return null;
394
+ }
395
+ if (!result.ok)
396
+ return null;
397
+ if (!result.value)
398
+ return null;
399
+ if (result.value.edits.length === 0)
400
+ return null;
401
+ return { files: result.value.files, edits: result.value.edits };
402
+ }
403
+ /**
404
+ * PUGI-78 Phase 1: code-action / quick-fix list at a range. Returns
405
+ * `[]` on any failure mode.
406
+ */
407
+ export async function codeActionsAt(transport, filePath, startLine, startChar, endLine, endChar) {
408
+ if (!isNonEmptyString(filePath))
409
+ return [];
410
+ if (!isFiniteNonNegativeInt(startLine) ||
411
+ !isFiniteNonNegativeInt(startChar) ||
412
+ !isFiniteNonNegativeInt(endLine) ||
413
+ !isFiniteNonNegativeInt(endChar)) {
414
+ return [];
415
+ }
416
+ if (!transport.codeActions)
417
+ return [];
418
+ const range = {
419
+ start: { line: startLine, character: startChar },
420
+ end: { line: endLine, character: endChar },
421
+ };
422
+ let result;
423
+ try {
424
+ result = await transport.codeActions(filePath, range);
425
+ }
426
+ catch {
427
+ return [];
428
+ }
429
+ if (!result.ok)
430
+ return [];
431
+ return [...result.value];
432
+ }
433
+ /**
434
+ * PUGI-78 Phase 1: formatter — returns the text edits the server would
435
+ * apply. Returns `[]` on any failure mode.
436
+ */
437
+ export async function formatFile(transport, filePath, options) {
438
+ if (!isNonEmptyString(filePath))
439
+ return [];
440
+ if (!transport.formatting)
441
+ return [];
442
+ let result;
443
+ try {
444
+ result = await transport.formatting(filePath, options);
445
+ }
446
+ catch {
447
+ return [];
448
+ }
449
+ if (!result.ok)
450
+ return [];
451
+ return [...result.value];
452
+ }
453
+ /**
454
+ * PUGI-78 Phase 1: diagnostics for `filePath`. Returns `[]` on any
455
+ * failure mode. Mirrors the agent surface of `lspDiagnostics` but
456
+ * folded into the symbols.* namespace for ergonomic discoverability.
457
+ */
458
+ export async function diagnosticsFor(transport, filePath) {
459
+ if (!isNonEmptyString(filePath))
460
+ return [];
461
+ if (!transport.diagnostics)
462
+ return [];
463
+ let result;
464
+ try {
465
+ result = await transport.diagnostics(filePath);
466
+ }
467
+ catch {
468
+ return [];
469
+ }
470
+ if (!result.ok)
471
+ return [];
472
+ return [...result.value];
473
+ }
474
+ /**
475
+ * PUGI-78 Phase 1: call hierarchy at a symbol position. Returns the
476
+ * incoming and outgoing edges. Empty arrays on any failure mode.
477
+ */
478
+ export async function callHierarchyAt(transport, filePath, line, character) {
479
+ const empty = { incoming: [], outgoing: [] };
480
+ if (!isNonEmptyString(filePath))
481
+ return empty;
482
+ if (!isFiniteNonNegativeInt(line) || !isFiniteNonNegativeInt(character))
483
+ return empty;
484
+ if (!transport.callHierarchy)
485
+ return empty;
486
+ let result;
487
+ try {
488
+ result = await transport.callHierarchy(filePath, { line, character });
489
+ }
490
+ catch {
491
+ return empty;
492
+ }
493
+ if (!result.ok)
494
+ return empty;
495
+ return { incoming: [...result.value.incoming], outgoing: [...result.value.outgoing] };
496
+ }
497
+ /**
498
+ * Internal: collapse `LspLocation[]` to flat references the agent
499
+ * surface ships. Identical to the `findReferences` loop body — lifted
500
+ * to avoid a second copy.
501
+ */
502
+ function collapseLocations(locations) {
503
+ const out = [];
504
+ for (const loc of locations) {
505
+ const file = pickFilePath(loc);
506
+ if (!isNonEmptyString(file))
507
+ continue;
508
+ out.push({
509
+ file,
510
+ line: loc.range.start.line,
511
+ character: loc.range.start.character,
512
+ });
513
+ }
514
+ return out;
515
+ }
225
516
  // -- internals ---------------------------------------------------------------
226
517
  function isNonEmptyString(value) {
227
518
  return typeof value === 'string' && value.trim().length > 0;
@@ -368,5 +659,6 @@ export const __test__ = {
368
659
  pickRangeStart,
369
660
  symbolKindLabel,
370
661
  walkDocumentSymbol,
662
+ collapseLocations,
371
663
  };
372
664
  //# sourceMappingURL=symbol-tools.js.map
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  /**
3
3
  * Pugi MCP server (β4 M2) — exposes Pugi's native tool surface to other
4
- * agents (the upstream tool, peer tooling, Codex CLI, any client that speaks
4
+ * agents (the upstream tool, peer tooling, peer CLI, any client that speaks
5
5
  * MCP).
6
6
  *
7
7
  * Transport-agnostic core. The stdio entry-point lives at the bottom of
@@ -4,7 +4,7 @@
4
4
  * Pugi's persona prompt teaches Pugi to emit two structured XML envelopes
5
5
  * when she would otherwise have to guess. Operator chat then pauses on a
6
6
  * modal until the operator answers, eliminating the "fabricate a default
7
- * silently" failure mode that Codex CLI, the upstream tool, and Gemini CLI all
7
+ * silently" failure mode that peer CLI, the upstream tool, and Gemini CLI all
8
8
  * trip on with low-confidence intents.
9
9
  *
10
10
  * <pugi-ask>