@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.13

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 (57) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/ask-modal.js +2 -2
  48. package/dist/tui/conversation-pane.js +1 -1
  49. package/dist/tui/input-box.js +1 -1
  50. package/dist/tui/markdown-render.js +4 -4
  51. package/dist/tui/repl-render.js +105 -15
  52. package/dist/tui/repl-splash.js +2 -2
  53. package/dist/tui/repl.js +10 -4
  54. package/dist/tui/splash.js +1 -1
  55. package/dist/tui/status-bar.js +94 -16
  56. package/dist/tui/update-banner.js +20 -2
  57. package/package.json +5 -4
@@ -34,6 +34,50 @@ const COMMON_LOCAL_FIRST_PREAMBLE = [
34
34
  'Cite file paths relative to the workspace root. Keep edits minimal and reversible.',
35
35
  'When you are done, return a single final text answer that the operator can read on the CLI.',
36
36
  ].join(' ');
37
+ /**
38
+ * β5a P1+P6 (2026-05-26): prompt v2 — intent marker contract +
39
+ * definitional examples + jargon ban. Fixes the dominant Pugi loss
40
+ * mode in the α7.X Phase 2 comparative eval: tool-use on pure
41
+ * knowledge questions ("What is grep?" → bash man grep).
42
+ *
43
+ * The CLI-side `classifyIntent` (apps/pugi-cli/src/core/engine/intent.ts)
44
+ * wraps definitional questions with `<intent kind="definitional">` on
45
+ * the user message before send. The rules below teach the model what
46
+ * to do with that marker.
47
+ *
48
+ * Voice constraint: same banned-jargon list as the cabinet Mira
49
+ * persona (брифую / диспатчу / шипаю and the English jargon list
50
+ * from BANNED_WORDS in mira.system-prompt.ts). Repeated here verbatim
51
+ * so the CLI surface has its own enforcement copy; the cabinet copy
52
+ * is the source of truth and ships through the runtime persona
53
+ * prompt for the cabinet UI. CLI runs DO NOT load the cabinet
54
+ * persona prompt — engine prompts are the only place to enforce
55
+ * voice for `pugi explain` / `pugi code` callers.
56
+ */
57
+ const INTENT_MARKER_CONTRACT = [
58
+ '# Intent contract',
59
+ 'When the operator\'s message starts with `<intent kind="definitional">`, treat it as a knowledge question:',
60
+ ' - Answer in prose. Do NOT call any tools.',
61
+ ' - Cite a file from the repo only when it directly supports the explanation.',
62
+ ' - Keep the answer to one short paragraph unless the operator asked for depth.',
63
+ 'When no intent marker is present OR the marker is operational, use tools as needed.',
64
+ '',
65
+ '# Definitional examples',
66
+ 'Operator: `<intent kind="definitional">What is grep?</intent>`',
67
+ 'You: `grep is a Unix command-line tool that searches plain text for lines matching a regular expression. It reads stdin or files and prints matching lines to stdout.`',
68
+ '(No tool calls. One paragraph. No file paths because grep is a generic Unix tool, not a repo artefact.)',
69
+ '',
70
+ 'Operator: `<intent kind="definitional">что такое pgvector?</intent>`',
71
+ 'You: `pgvector - это расширение PostgreSQL для хранения и поиска векторных эмбеддингов. Поддерживает ivfflat и hnsw индексы; используется для RAG и semantic search.`',
72
+ '(One short paragraph. No tools.)',
73
+ ].join('\n');
74
+ const JARGON_BAN = [
75
+ '# Voice',
76
+ 'Brand voice: terse, operator-grade, English in code paths / Russian-Ukrainian permitted in chat answers.',
77
+ 'Banned words (CLI customer-facing output): "брифую", "диспатчу", "шипаю", "journey", "explore", "delight", "magical", "friendly", "let me help", "I\'d be happy to", "pug-tastic". Use neutral verbs instead: brief / dispatch / ship / build / read / write.',
78
+ 'No em-dashes. No emoji. No "AI assistant" framing.',
79
+ ].join('\n');
80
+ const PROMPT_V2_APPENDIX = [INTENT_MARKER_CONTRACT, JARGON_BAN].join('\n\n');
37
81
  const PLAN_TOOLS_NOTE = 'PLAN MODE IS READ-ONLY. You may call read, grep, glob. Calls to write, edit, or bash will be refused and end the run. Produce a written plan, not changes.';
38
82
  const EDIT_FLOW_RULES = [
39
83
  'Before calling edit on a file you have not yet read this session, call read first — the edit tool fails otherwise.',
@@ -42,10 +86,16 @@ const EDIT_FLOW_RULES = [
42
86
  ].join(' ');
43
87
  export function systemPromptFor(kind) {
44
88
  const base = baseSystemPromptFor(kind);
89
+ // β5a P1+P6: append the prompt-v2 intent-marker contract +
90
+ // definitional examples + jargon ban to every command kind. The
91
+ // appendix is shared across kinds because the marker contract +
92
+ // voice gate are command-agnostic — a definitional question lands
93
+ // the same way under `pugi explain` and `pugi code`.
94
+ const withV2 = `${base}\n\n${PROMPT_V2_APPENDIX}`;
45
95
  const snapshot = formatBackgroundJobsSnapshot(getJobRegistrySafely());
46
96
  if (!snapshot)
47
- return base;
48
- return `${base}\n\n${snapshot}`;
97
+ return withV2;
98
+ return `${withV2}\n\n${snapshot}`;
49
99
  }
50
100
  function baseSystemPromptFor(kind) {
51
101
  switch (kind) {
@@ -4,6 +4,10 @@ import { askUser } from '../../tools/ask-user.js';
4
4
  import { skillInvoke, skillList } from '../../tools/skill-tool.js';
5
5
  import { taskCreate, taskGet, taskList, taskUpdate, } from '../../tools/tasks.js';
6
6
  import { webFetchTool } from '../../tools/web-fetch.js';
7
+ import { webSearchTool } from '../../tools/web-search.js';
8
+ import { agentTool } from '../../tools/agent-tool.js';
9
+ import { multiEdit } from '../../tools/multi-edit.js';
10
+ import { buildMcpToolDefs, defaultNonInteractiveMcpPrompt, dispatchMcpTool, MCP_TOOL_PREFIX, } from '../../tools/mcp-tool.js';
7
11
  /**
8
12
  * Tool-bridge: turns the abstract tool registry into:
9
13
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -45,6 +49,12 @@ const READ_ONLY_TOOLS = new Set([
45
49
  'task_list',
46
50
  'task_update',
47
51
  'web_fetch',
52
+ // β1b T4 (2026-05-26): web_search is read-only from the workspace's
53
+ // perspective (no file writes, no shell). Egress goes through the
54
+ // Anvil-proxied Brave Search API, gated by the same opt-in posture as
55
+ // web_fetch. Plan mode keeps the tool available because reading the
56
+ // web is part of how a plan is researched.
57
+ 'web_search',
48
58
  ]);
49
59
  /**
50
60
  * Tools the engine loop dispatches. β1 expands the M1 cornerstone six
@@ -68,9 +78,25 @@ const WIRED_TOOLS = new Set([
68
78
  'task_list',
69
79
  'task_update',
70
80
  'web_fetch',
81
+ // β1b T4: see READ_ONLY_TOOLS above.
82
+ 'web_search',
83
+ // β2 S3 (2026-05-26): real subagent spawn primitive. Only advertised
84
+ // when buildToolsSchema is called with allowAgent=true (orchestrator
85
+ // / root Mira context); plan-mode also excludes it because spawning
86
+ // a write-capable child violates plan-mode's read-only contract.
87
+ 'agent',
88
+ // β7 L5+T11 (2026-05-26): transactional multi-file edit. Routes
89
+ // through the same security gate as Layer A/B/C; not advertised in
90
+ // plan mode (mutation surface).
91
+ 'multi_edit',
71
92
  ]);
72
- export function buildToolsSchema(kind, options = { allowFetch: false }) {
93
+ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearch: false }) {
73
94
  const planMode = kind === 'plan';
95
+ // β4 M1/M3: splice MCP tools BEFORE the native list assembly so the
96
+ // engine-loop sees them in stable alphabetical order alongside native
97
+ // tools. We keep the entries appended after the native push so plan-
98
+ // mode can be filtered by namespace prefix in one place at the end.
99
+ const mcpDefs = buildMcpToolDefs(options.mcpRegistry);
74
100
  const toolDefs = [
75
101
  {
76
102
  name: 'read',
@@ -224,6 +250,85 @@ export function buildToolsSchema(kind, options = { allowFetch: false }) {
224
250
  },
225
251
  });
226
252
  }
253
+ // β1b T4 (2026-05-26): web_search advertisement. Same off-by-default
254
+ // privacy posture as web_fetch — the query string itself is an egress
255
+ // event that can leak operator intent to the upstream Brave Search
256
+ // backend. The tool dispatcher applies SSRF guards (no localhost via
257
+ // the Anvil proxy URL), rate-limits (5 req/min per session), and caps
258
+ // the result payload at 1 MiB. Sentinel-wrapped results so the model
259
+ // treats every snippet as data, not instructions.
260
+ if (options.allowSearch) {
261
+ toolDefs.push({
262
+ name: 'web_search',
263
+ description: 'Search the web via Brave Search (Anvil-proxied). Returns up to 10 sentinel-wrapped {title, url, snippet} results. Rate-limited to 5 calls/min per session. Gated off by default.',
264
+ parameters: {
265
+ type: 'object',
266
+ additionalProperties: false,
267
+ required: ['query'],
268
+ properties: {
269
+ query: {
270
+ type: 'string',
271
+ description: 'Search query, max 256 chars. Plain text — no operators.',
272
+ },
273
+ count: {
274
+ type: 'integer',
275
+ description: 'Optional result count (1..10, default 10).',
276
+ },
277
+ },
278
+ },
279
+ });
280
+ }
281
+ // β2 S3 (2026-05-26): `agent` tool — subagent spawn primitive.
282
+ // Off by default; surfaced only when the caller explicitly opts in
283
+ // (orchestrator parents pass allowAgent=true via the engine adapter).
284
+ // Plan mode FORCES the tool off regardless because a write-capable
285
+ // child would violate plan-mode's read-only contract.
286
+ if (options.allowAgent && !planMode) {
287
+ toolDefs.push({
288
+ name: 'agent',
289
+ description: 'Spawn a specialist subagent under a Cyber-Zoo brand persona. '
290
+ + 'Role selects the persona + isolation tier: '
291
+ + 'researcher/reviewer/architect are read-only, verifier reads + runs tests, '
292
+ + 'coder/release/devops/design_qa get write + bash. '
293
+ + 'The child runs a fresh Anvil engine loop with its own transcript and '
294
+ + 'returns a JSON envelope (filesChanged, toolCallCount, status, summary). '
295
+ + 'Use this when the work needs a specialist persona OR write isolation via a scratch worktree.',
296
+ parameters: {
297
+ type: 'object',
298
+ additionalProperties: false,
299
+ required: ['role', 'brief'],
300
+ properties: {
301
+ role: {
302
+ type: 'string',
303
+ enum: [
304
+ 'orchestrator',
305
+ 'architect',
306
+ 'coder',
307
+ 'verifier',
308
+ 'reviewer',
309
+ 'researcher',
310
+ 'release',
311
+ 'devops',
312
+ 'design_qa',
313
+ ],
314
+ description: 'SubagentRole — selects persona + isolation tier.',
315
+ },
316
+ brief: {
317
+ type: 'string',
318
+ maxLength: 8000,
319
+ description: 'One-paragraph task description forwarded to the child as the user prompt. '
320
+ + 'Be concrete: include filenames, expected behavior, and acceptance criteria.',
321
+ },
322
+ isolation: {
323
+ type: 'string',
324
+ enum: ['worktree', 'shared_fs', 'auto'],
325
+ description: 'Optional override. `worktree` forces a scratch git worktree for write isolation; '
326
+ + '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.',
327
+ },
328
+ },
329
+ },
330
+ });
331
+ }
227
332
  if (!planMode) {
228
333
  toolDefs.push({
229
334
  name: 'write',
@@ -261,8 +366,57 @@ export function buildToolsSchema(kind, options = { allowFetch: false }) {
261
366
  command: { type: 'string', description: 'Single shell command to execute.' },
262
367
  },
263
368
  },
369
+ },
370
+ // β7 L5+T11 (2026-05-26): transactional multi-file edit. Either
371
+ // all entries land or none do — failures roll the workspace back
372
+ // via the same journal + snapshot machinery the dispatcher uses.
373
+ // Cap is 50 entries; beyond that the operator (or model) should
374
+ // split the refactor or use Layer C rewrites.
375
+ {
376
+ name: 'multi_edit',
377
+ description: 'Apply an ordered batch of single-occurrence file edits as one transaction. ' +
378
+ 'Each entry is {file, oldString, newString} like the `edit` tool. Either every ' +
379
+ 'edit lands or none do — a failure rolls the workspace back to the pre-dispatch ' +
380
+ 'state via journal + snapshot. Cap 50 edits per call. Use this for coordinated ' +
381
+ 'refactors (rename across files, add an import to many modules).',
382
+ parameters: {
383
+ type: 'object',
384
+ additionalProperties: false,
385
+ required: ['edits'],
386
+ properties: {
387
+ edits: {
388
+ type: 'array',
389
+ minItems: 1,
390
+ maxItems: 50,
391
+ items: {
392
+ type: 'object',
393
+ additionalProperties: false,
394
+ required: ['file', 'oldString', 'newString'],
395
+ properties: {
396
+ file: { type: 'string', description: 'Workspace-relative file path.' },
397
+ oldString: { type: 'string', description: 'Verbatim substring; must be unique in the pre-edit file.' },
398
+ newString: { type: 'string', description: 'Replacement string. Empty string means delete.' },
399
+ },
400
+ },
401
+ },
402
+ },
403
+ },
264
404
  });
265
405
  }
406
+ // β4 M1/M3: append MCP tools last. Plan mode skips them because every
407
+ // MCP tool is treated as medium-risk until per-tool annotations land
408
+ // in the MCP spec; treating MCP read-as-read would require server-
409
+ // side metadata we cannot trust today (a misconfigured server could
410
+ // claim `read` while running a destructive op).
411
+ if (!planMode) {
412
+ for (const def of mcpDefs) {
413
+ toolDefs.push({
414
+ name: def.name,
415
+ description: def.description,
416
+ parameters: def.parameters,
417
+ });
418
+ }
419
+ }
266
420
  return toolDefs;
267
421
  }
268
422
  function parseArgs(raw) {
@@ -287,18 +441,27 @@ function requireString(obj, key) {
287
441
  return v;
288
442
  }
289
443
  export function buildExecutor(input) {
290
- const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch } = input;
444
+ const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry } = input;
445
+ const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
291
446
  const workspaceRoot = input.workspaceRoot ?? ctx.root;
292
447
  const planMode = kind === 'plan';
293
448
  return async ({ name, arguments: argsRaw }) => {
294
- if (!WIRED_TOOLS.has(name)) {
449
+ // β4 M1/M3: MCP tool names live outside WIRED_TOOLS. They are
450
+ // validated lazily by the dispatcher (the registry knows which
451
+ // names are actually exposed). The namespace check happens FIRST
452
+ // so a bad `mcp__bogus__foo` does not collide with the native
453
+ // unknown-tool branch.
454
+ const isMcpName = name.startsWith(MCP_TOOL_PREFIX);
455
+ if (!isMcpName && !WIRED_TOOLS.has(name)) {
295
456
  throw new Error(`unknown tool: ${name}`);
296
457
  }
297
- if (planMode && !READ_ONLY_TOOLS.has(name)) {
298
- // Sentinel recognised by `runEngineLoop` terminates the loop
299
- // with status `tool_refused`. The CLI surfaces this as a blocked
300
- // outcome, not a failure, because plan mode is doing its job.
301
- throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
458
+ if (planMode) {
459
+ // MCP tools are uniformly refused in plan mode (see schema-side
460
+ // rationale in buildToolsSchema). Native tools split via
461
+ // READ_ONLY_TOOLS as before.
462
+ if (isMcpName || !READ_ONLY_TOOLS.has(name)) {
463
+ throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
464
+ }
302
465
  }
303
466
  // α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
304
467
  // hooks fire so a cancelled brief never reaches user-defined
@@ -336,8 +499,21 @@ export function buildExecutor(input) {
336
499
  }
337
500
  }
338
501
  }
339
- const args = parseArgs(argsRaw);
502
+ // β4 M1/M3: MCP dispatch deferred to the `dispatch` closure below so
503
+ // PostToolUse / PostToolUseFailure hooks observe MCP calls just like
504
+ // native calls. The dispatcher does its own argument parsing — MCP
505
+ // arg errors surface as model-visible `[MCP dispatch error] ...`
506
+ // strings, not throws.
507
+ const args = isMcpName ? {} : parseArgs(argsRaw);
340
508
  const dispatch = async () => {
509
+ if (isMcpName) {
510
+ return dispatchMcpTool({
511
+ name,
512
+ argumentsRaw: argsRaw,
513
+ registry: mcpRegistry,
514
+ prompt: mcpPrompt,
515
+ });
516
+ }
341
517
  // β1 T1/T2/T3/T5/T6: async-dispatch the new tool surface.
342
518
  // task_*, skill, ask_user_question, web_fetch all live behind
343
519
  // an async or async-compatible boundary.
@@ -353,6 +529,33 @@ export function buildExecutor(input) {
353
529
  if (name === 'web_fetch') {
354
530
  return dispatchWebFetch(args, { ctx, allowFetch: Boolean(allowFetch) });
355
531
  }
532
+ if (name === 'web_search') {
533
+ return dispatchWebSearch(args, {
534
+ ctx,
535
+ allowSearch: Boolean(allowSearch),
536
+ sessionId,
537
+ });
538
+ }
539
+ if (name === 'multi_edit') {
540
+ return dispatchMultiEdit(args, ctx);
541
+ }
542
+ if (name === 'agent') {
543
+ // β2a r1 (Backend Architect P1, 2026-05-26): defense in depth.
544
+ // `WIRED_TOOLS` includes `agent`, so a plan-mode model that
545
+ // fabricates an `agent` tool call would otherwise be routed
546
+ // here. The plan-mode refusal at the top of the executor only
547
+ // fires for tools NOT in READ_ONLY_TOOLS; `agent` is
548
+ // intentionally absent from both sets, so we explicitly refuse
549
+ // it here. This pairs with `native-pugi.ts` hard-gating
550
+ // `agentDispatch` itself off in plan mode — without this
551
+ // defensive throw a future schema bug could let a plan-mode
552
+ // model spawn a write-capable child and break the read-only
553
+ // contract.
554
+ if (planMode) {
555
+ throw new Error('PLAN_MODE_REFUSED: agent is not allowed in plan mode');
556
+ }
557
+ return dispatchAgent(args, agentDispatch);
558
+ }
356
559
  return dispatchTool(name, args, ctx);
357
560
  };
358
561
  try {
@@ -592,6 +795,71 @@ async function dispatchWebFetch(args, opts) {
592
795
  });
593
796
  return JSON.stringify(result);
594
797
  }
798
+ async function dispatchWebSearch(args, opts) {
799
+ const query = requireString(args, 'query');
800
+ // `count` is optional integer 1..10. Validate here so the tool layer
801
+ // gets a clean value (the tool clamps internally too — defense in
802
+ // depth, since the model can pass anything).
803
+ let count;
804
+ if (args['count'] !== undefined && args['count'] !== null) {
805
+ const n = args['count'];
806
+ if (typeof n !== 'number' || !Number.isInteger(n)) {
807
+ throw new Error('web_search: count must be an integer');
808
+ }
809
+ count = n;
810
+ }
811
+ const result = await webSearchTool({ query, ...(count !== undefined ? { count } : {}) }, {
812
+ settings: opts.ctx.settings,
813
+ allowSearch: opts.allowSearch,
814
+ sessionId: opts.sessionId,
815
+ });
816
+ return JSON.stringify(result);
817
+ }
818
+ /**
819
+ * β2 S3 dispatch — wire the model-emitted `agent` tool call to the
820
+ * real subagent spawn primitive. When the executor was built without
821
+ * `agentDispatch` (e.g. a child loop, or a parent that explicitly
822
+ * disabled subagent spawn), the call is refused with a structured
823
+ * envelope so the model can adapt instead of crashing the parent loop.
824
+ */
825
+ async function dispatchAgent(args, opts) {
826
+ if (!opts) {
827
+ // No dispatch context — return a structured refusal envelope.
828
+ // This matches the agent-tool.ts no-engine-client path and lets
829
+ // the model decide whether to retry inline or abandon the
830
+ // delegation. Throwing here would terminate the parent on a tool
831
+ // error frame which is the wrong UX when the issue is config.
832
+ return JSON.stringify({
833
+ ok: false,
834
+ status: 'failed',
835
+ summary: 'agent tool refused: dispatch not wired in this engine adapter. '
836
+ + 'Re-run from a parent loop with agentDispatch configured.',
837
+ });
838
+ }
839
+ const parsed = parseAgentArgs(args);
840
+ const result = await agentTool(parsed, {
841
+ session: opts.parentSession,
842
+ engineClient: opts.engineClient,
843
+ ...(opts.parentBudgetRemaining
844
+ ? { parentBudgetRemaining: opts.parentBudgetRemaining }
845
+ : {}),
846
+ });
847
+ return JSON.stringify(result);
848
+ }
849
+ function parseAgentArgs(args) {
850
+ // Surface a clean error message to the model when the args don't
851
+ // match the schema. agentTool itself also validates via Zod; this
852
+ // pre-parse layer keeps the error stack short.
853
+ const role = requireString(args, 'role');
854
+ const brief = requireString(args, 'brief');
855
+ const isolationRaw = optionalString(args, 'isolation');
856
+ const out = {
857
+ role: role,
858
+ brief,
859
+ ...(isolationRaw ? { isolation: isolationRaw } : {}),
860
+ };
861
+ return out;
862
+ }
595
863
  function optionalString(obj, key) {
596
864
  const v = obj[key];
597
865
  if (v === undefined || v === null)
@@ -601,4 +869,38 @@ function optionalString(obj, key) {
601
869
  }
602
870
  return v;
603
871
  }
872
+ /**
873
+ * β7 L5+T11: dispatch the model-emitted `multi_edit` tool call. The
874
+ * tool returns a structured result envelope; we serialize it to JSON
875
+ * for the engine loop. A refused dispatch (security, no_match,
876
+ * ambiguous_match, etc.) surfaces as `ok: false` in the envelope —
877
+ * the model can re-strategise rather than crashing the loop.
878
+ */
879
+ function dispatchMultiEdit(args, ctx) {
880
+ const raw = args['edits'];
881
+ if (!Array.isArray(raw)) {
882
+ throw new Error('multi_edit: edits must be an array');
883
+ }
884
+ const edits = raw.map((item, i) => {
885
+ if (!item || typeof item !== 'object') {
886
+ throw new Error(`multi_edit: edits[${i}] must be an object`);
887
+ }
888
+ const obj = item;
889
+ const file = obj['file'];
890
+ const oldString = obj['oldString'];
891
+ const newString = obj['newString'];
892
+ if (typeof file !== 'string') {
893
+ throw new Error(`multi_edit: edits[${i}].file must be a string`);
894
+ }
895
+ if (typeof oldString !== 'string') {
896
+ throw new Error(`multi_edit: edits[${i}].oldString must be a string`);
897
+ }
898
+ if (typeof newString !== 'string') {
899
+ throw new Error(`multi_edit: edits[${i}].newString must be a string`);
900
+ }
901
+ return { file, oldString, newString };
902
+ });
903
+ const result = multiEdit(ctx, edits);
904
+ return JSON.stringify(result);
905
+ }
604
906
  //# sourceMappingURL=tool-bridge.js.map
@@ -465,10 +465,59 @@ export class LspClient {
465
465
  }
466
466
  }
467
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
+ }
468
513
  /**
469
514
  * Start an LSP client for the given language. Returns either an `LspClient`
470
515
  * ready to use, or a structured failure (`lsp_unavailable`,
471
516
  * `language_unsupported`).
517
+ *
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.
472
521
  */
473
522
  export async function startLspClient(lang, opts) {
474
523
  const server = opts.serverOverride ?? LANGUAGE_SERVERS[lang];
@@ -479,6 +528,14 @@ export async function startLspClient(lang, opts) {
479
528
  detail: `no LSP server registered for language: ${lang}`,
480
529
  };
481
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
+ }
482
539
  if (!opts.serverOverride) {
483
540
  const available = detectBinary(server.probe);
484
541
  if (!available) {
@@ -34,6 +34,15 @@ import { z } from 'zod';
34
34
  */
35
35
  export const mcpServerConfigSchema = z.object({
36
36
  command: z.string().min(1),
37
+ /**
38
+ * β4 r2 P1 #1 — operator's literal `command` argument as supplied to
39
+ * `pugi mcp install`. `command` above is now the RESOLVED absolute path
40
+ * we actually spawn. `originalCommand` is preserved for display/audit
41
+ * (`pugi mcp list`, audit logs) so operators can still see how they
42
+ * typed the install. Optional because pre-r2 mcp.json files do not
43
+ * carry the field — the loader treats absence as "use command directly".
44
+ */
45
+ originalCommand: z.string().min(1).optional(),
37
46
  args: z.array(z.string()).default([]),
38
47
  env: z.record(z.string()).default({}),
39
48
  /**