@pugi/cli 0.1.0-beta.1 → 0.1.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/dist/core/edits/worktree.js +322 -0
  4. package/dist/core/engine/anvil-client.js +16 -0
  5. package/dist/core/engine/budgets.js +89 -0
  6. package/dist/core/engine/native-pugi.js +112 -12
  7. package/dist/core/engine/prompts.js +8 -0
  8. package/dist/core/engine/tool-bridge.js +267 -8
  9. package/dist/core/init/scaffold.js +195 -0
  10. package/dist/core/lsp/client.js +719 -0
  11. package/dist/core/repl/codebase-survey.js +308 -0
  12. package/dist/core/repl/init-interview.js +457 -0
  13. package/dist/core/repl/onboarding-state.js +297 -0
  14. package/dist/core/repl/session.js +72 -1
  15. package/dist/core/repl/slash-commands.js +41 -0
  16. package/dist/core/settings.js +28 -0
  17. package/dist/core/skills/defaults.js +457 -0
  18. package/dist/runtime/cli.js +366 -14
  19. package/dist/runtime/commands/delegate.js +289 -0
  20. package/dist/runtime/commands/lsp.js +206 -0
  21. package/dist/runtime/commands/patch.js +128 -0
  22. package/dist/runtime/commands/roster.js +117 -0
  23. package/dist/runtime/commands/worktree.js +177 -0
  24. package/dist/runtime/plan-decompose.js +531 -0
  25. package/dist/tools/apply-patch.js +495 -0
  26. package/dist/tools/ask-user.js +115 -0
  27. package/dist/tools/lsp-tools.js +189 -0
  28. package/dist/tools/registry.js +26 -0
  29. package/dist/tools/skill-tool.js +96 -0
  30. package/dist/tools/tasks.js +208 -0
  31. package/dist/tui/ask-modal.js +2 -2
  32. package/dist/tui/conversation-pane.js +1 -1
  33. package/dist/tui/input-box.js +1 -1
  34. package/dist/tui/markdown-render.js +4 -4
  35. package/dist/tui/repl-render.js +169 -10
  36. package/dist/tui/repl-splash.js +2 -2
  37. package/dist/tui/repl.js +18 -5
  38. package/dist/tui/splash.js +1 -1
  39. package/dist/tui/update-banner.js +1 -1
  40. package/docs/examples/codegraph.mcp.json +10 -0
  41. package/package.json +6 -4
@@ -1,9 +1,10 @@
1
1
  import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { defaultEngineBudgets, runEngineLoop, } from '@pugi/sdk';
3
+ import { runEngineLoop, } from '@pugi/sdk';
4
4
  import { FileReadCache } from '../file-cache.js';
5
5
  import { loadSettings } from '../settings.js';
6
6
  import { openSession, recordToolCall, recordToolResult } from '../session.js';
7
+ import { resolveBudget } from './budgets.js';
7
8
  import { buildExecutor, buildToolsSchema } from './tool-bridge.js';
8
9
  import { personaSlugFor, systemPromptFor } from './prompts.js';
9
10
  /**
@@ -73,15 +74,21 @@ export class NativePugiEngineAdapter {
73
74
  session,
74
75
  readCache: new FileReadCache(),
75
76
  };
76
- const budget = task.budget?.tokens
77
- ? {
78
- maxTokens: task.budget.tokens,
79
- // The task-level budget only carries tokens; tool calls keep
80
- // the per-command default so a careless caller cannot disable
81
- // the call-count guard by overriding usd/tokens.
82
- maxToolCalls: defaultEngineBudgets[kind].maxToolCalls,
83
- }
84
- : defaultEngineBudgets[kind];
77
+ // β1a r1 (budget wiring, 2026-05-26): swap the legacy SDK per-
78
+ // command budget lookup for the Pl9 `resolveBudget()` pipeline so
79
+ // `.pugi/settings.json::budgets.<command>` overrides actually take
80
+ // effect at runtime + the HARD_MAX_* caps guard misconfigured
81
+ // envelopes pre-flight. Before this fix the β1 Pl9 module
82
+ // (`core/engine/budgets.ts`) was dead code the adapter still
83
+ // read the per-command defaults from the SDK, so operators who
84
+ // set `budgets.code.maxTokens = 50000` in settings.json got the
85
+ // legacy 30k anyway and `assertBudgetWithinTier` never ran.
86
+ //
87
+ // Task-level token override (e.g. CLI `--max-tokens`) keeps
88
+ // precedence; tool-call ceiling falls through to the resolved
89
+ // budget so a careless caller cannot disable the call-count
90
+ // guard by setting only token count.
91
+ const budget = resolveBudget(kind, settings, task.budget?.tokens ? { maxTokens: task.budget.tokens } : undefined);
85
92
  yield {
86
93
  type: 'status',
87
94
  message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
@@ -206,15 +213,48 @@ export class NativePugiEngineAdapter {
206
213
  try {
207
214
  outcome = await runEngineLoop({
208
215
  client: this.options.client,
209
- executor: buildExecutor({ kind, ctx: toolCtx }),
216
+ executor: buildExecutor({
217
+ kind,
218
+ ctx: toolCtx,
219
+ sessionId: session.id,
220
+ workspaceRoot: root,
221
+ // Conservatively false unless the caller explicitly opted in
222
+ // via constructor. Interactive ask-modal bridge is wired by
223
+ // the REPL layer in β2; for now non-TTY envelope is the path.
224
+ interactive: false,
225
+ // β1a r1 (web_fetch gating): executor allowFetch matches the
226
+ // schema-advertise gate so a settings.json opt-in actually
227
+ // enables the call. Without this the model would not even
228
+ // see the `web_fetch` tool, but a `pugi web` CLI dispatch
229
+ // through the executor would still be allowed because the
230
+ // tool registry is independent.
231
+ allowFetch: settings.web?.fetch?.enabled === true,
232
+ }),
210
233
  systemPrompt: systemPromptFor(kind),
211
234
  userPrompt: task.prompt,
212
- tools: buildToolsSchema(kind),
235
+ // β1a r1 (web_fetch gating): pass the OR of
236
+ // `.pugi/settings.json::web.fetch.enabled` and the runtime
237
+ // `allowFetch` flag (today the adapter is conservative — see
238
+ // `buildExecutor` call below). When neither is true the
239
+ // `web_fetch` tool is not advertised to the model at all.
240
+ tools: buildToolsSchema(kind, {
241
+ allowFetch: settings.web?.fetch?.enabled === true,
242
+ }),
213
243
  budget,
214
244
  personaSlug: personaSlugFor(kind),
215
245
  hooks,
216
246
  temperature: this.options.temperature ?? 0.2,
217
247
  signal: ctx.signal,
248
+ // β1 (audit E2): forward CLI sub-command + α6.10 routing tag +
249
+ // operator-pinned model so the runtime controller's DTO sees
250
+ // all three. `tag` derives 1:1 from `command` for now
251
+ // (`code → code`, `build → build_task`, etc.); future routing
252
+ // changes flip the mapping table without touching the call
253
+ // site. `model` is left undefined here — operator-pinned model
254
+ // pinning ships in β6 with persona routing.
255
+ command: kind,
256
+ tag: dispatchTagFor(kind),
257
+ model: this.options.model,
218
258
  });
219
259
  }
220
260
  catch (error) {
@@ -367,6 +407,66 @@ function toCommandKind(kind) {
367
407
  return 'build';
368
408
  return kind;
369
409
  }
410
+ /**
411
+ * β1 (audit E2) → β1a r1 (engine tag contract fix, 2026-05-26): map a
412
+ * CLI command kind to its α6.10 dispatch tag.
413
+ *
414
+ * The admin-api controller (`pugi-engine.controller.ts`) routes per-tag
415
+ * to a model/persona pair via
416
+ * `apps/admin-api/src/mira/routing/dispatch-tag.ts::DISPATCH_TAGS`. The
417
+ * closed `EngineChatTag` vocabulary is
418
+ * `classify | reason | codegen | summarize | vision` — note that
419
+ * `code`, `fix`, `plan`, `build`, `explain` (CLI command names) are NOT
420
+ * in this set.
421
+ *
422
+ * Before this fix `dispatchTagFor()` returned the CLI command names
423
+ * as-is and the runtime DTO rejected the payload with HTTP 400
424
+ * (`tag must be one of: classify, reason, codegen, summarize, vision`)
425
+ * before ever reaching the routing layer. Every `pugi code/fix/plan/
426
+ * build/explain` against the live runtime returned `failed: HTTP 400`.
427
+ *
428
+ * Mapping rationale (each row keeps the most informative `tag` value
429
+ * for cost telemetry / model selection):
430
+ *
431
+ * - `code`, `fix` → `codegen` (edits / diffs / patches)
432
+ * - `build_task`/`build` → `codegen` + `budget_hint: 'max'`
433
+ * (scaffolding hits the 30-call / 80k-token ceiling — give the
434
+ * router permission to pick the largest model in the tier)
435
+ * - `plan` → `reason` (no mutations, long-form thought)
436
+ * - `explain` → `summarize` (read-only walkthrough)
437
+ *
438
+ * `priority: 'realtime'` for every command — Pugi is an interactive
439
+ * CLI; background dispatch is reserved for the cabinet's RAG ingest
440
+ * cron path. `budget_hint: 'std'` is the default for the cost-balanced
441
+ * router row; only `build_task` opts up to `'max'`.
442
+ */
443
+ export function dispatchTagFor(kind) {
444
+ switch (kind) {
445
+ case 'code':
446
+ case 'fix':
447
+ return { tag: 'codegen', priority: 'realtime', budget_hint: 'std' };
448
+ case 'build':
449
+ // `build_task` on the engine task kind side is the heavy
450
+ // scaffolding lane — biggest budget envelope, biggest model
451
+ // permitted via `budget_hint: 'max'`.
452
+ return { tag: 'codegen', priority: 'realtime', budget_hint: 'max' };
453
+ case 'plan':
454
+ return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
455
+ case 'explain':
456
+ return { tag: 'summarize', priority: 'realtime', budget_hint: 'std' };
457
+ default: {
458
+ // Exhaustiveness check — `EngineCommandKind` is a closed union,
459
+ // so the switch above covers every case. If a new command kind
460
+ // is added the compiler flags this branch and the map must be
461
+ // extended. Fall back to `reason` as the most conservative
462
+ // routing choice so a future kind addition cannot accidentally
463
+ // unlock a write-heavy model lane.
464
+ const exhaustive = kind;
465
+ void exhaustive;
466
+ return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
467
+ }
468
+ }
469
+ }
370
470
  // The per-adapter `engineToolCallIds` Map lives on the
371
471
  // `NativePugiEngineAdapter` instance above — Code Reviewer P2 retro
372
472
  // 2026-05-23 lifted it off the module scope to prevent collisions
@@ -22,6 +22,14 @@ import { getJobRegistry, summarizeJobsForPrompt, } from '../jobs/registry.js';
22
22
  const COMMON_LOCAL_FIRST_PREAMBLE = [
23
23
  'You are the Pugi CLI agent running locally inside the operator\'s repository.',
24
24
  'The local filesystem is the source of truth. Every change you make is committed locally; nothing is uploaded by default (ADR-0037 local-first).',
25
+ // R1 fix (2026-05-26, PR #413 r1, Fix 5 Option B): only advertise the
26
+ // tools currently wired in `tool-bridge.ts::WIRED_TOOLS`. α7.7 ships
27
+ // apply_patch / lsp_* / worktree_* as CLI-only surfaces (`pugi patch`,
28
+ // `pugi lsp`, `pugi worktree`); wiring them into the engine loop is
29
+ // deferred to β2 (apply_patch), β4 (LSP tools), β7 (worktree tools)
30
+ // per the consolidated sprint plan. Advertising them in the system
31
+ // prompt without a matching executor entry caused Mira to attempt
32
+ // calls that returned `unknown_tool` — broken eval surface.
25
33
  'You have a tool registry: read, write, edit, grep, glob, bash. Call tools to inspect and modify the workspace.',
26
34
  'Cite file paths relative to the workspace root. Keep edits minimal and reversible.',
27
35
  'When you are done, return a single final text answer that the operator can read on the CLI.',
@@ -1,5 +1,9 @@
1
1
  import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
2
2
  import { bashToolSync } from '../../tools/bash.js';
3
+ import { askUser } from '../../tools/ask-user.js';
4
+ import { skillInvoke, skillList } from '../../tools/skill-tool.js';
5
+ import { taskCreate, taskGet, taskList, taskUpdate, } from '../../tools/tasks.js';
6
+ import { webFetchTool } from '../../tools/web-fetch.js';
3
7
  /**
4
8
  * Tool-bridge: turns the abstract tool registry into:
5
9
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -23,16 +27,49 @@ import { bashToolSync } from '../../tools/bash.js';
23
27
  /**
24
28
  * Read-only subset surfaced to plan-mode. Mutating tools (write, edit,
25
29
  * bash) are intentionally absent so the model rarely tries them.
30
+ *
31
+ * β1: task_* + skill + ask_user_question + web_fetch are all read-only
32
+ * from the workspace's perspective (no file writes), so they stay
33
+ * available in plan mode. The ledger writes for `task_*` land in
34
+ * `.pugi/sessions/<id>/tasks.jsonl` which is metadata, not source.
26
35
  */
27
- const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob']);
36
+ const READ_ONLY_TOOLS = new Set([
37
+ 'read',
38
+ 'grep',
39
+ 'glob',
40
+ 'ask_user_question',
41
+ 'skill',
42
+ 'skills_list',
43
+ 'task_create',
44
+ 'task_get',
45
+ 'task_list',
46
+ 'task_update',
47
+ 'web_fetch',
48
+ ]);
28
49
  /**
29
- * Tools we actually wire today. The registry has more entries
30
- * (task_*, skill, question) those route through the runtime layer, not
31
- * the local filesystem, so they ship in a follow-up PR. M1 cornerstone is
32
- * the six core tools.
50
+ * Tools the engine loop dispatches. β1 expands the M1 cornerstone six
51
+ * (read/write/edit/grep/glob/bash) with task_* + ask_user_question +
52
+ * skill + skill list + web_fetch. The registry advertises these slots
53
+ * to the runtime; without dispatcher entries the model would call
54
+ * "unknown tool" errors.
33
55
  */
34
- const WIRED_TOOLS = new Set(['read', 'write', 'edit', 'grep', 'glob', 'bash']);
35
- export function buildToolsSchema(kind) {
56
+ const WIRED_TOOLS = new Set([
57
+ 'read',
58
+ 'write',
59
+ 'edit',
60
+ 'grep',
61
+ 'glob',
62
+ 'bash',
63
+ 'ask_user_question',
64
+ 'skill',
65
+ 'skills_list',
66
+ 'task_create',
67
+ 'task_get',
68
+ 'task_list',
69
+ 'task_update',
70
+ 'web_fetch',
71
+ ]);
72
+ export function buildToolsSchema(kind, options = { allowFetch: false }) {
36
73
  const planMode = kind === 'plan';
37
74
  const toolDefs = [
38
75
  {
@@ -72,6 +109,121 @@ export function buildToolsSchema(kind) {
72
109
  },
73
110
  },
74
111
  ];
112
+ // β1 T1/T6: TodoWrite (Pugi grammar = `task_*`). Append-only ledger
113
+ // at `.pugi/sessions/<id>/tasks.jsonl`.
114
+ toolDefs.push({
115
+ name: 'task_create',
116
+ description: 'Append a new task to the session todo ledger. Returns the assigned task id and full record. Mirrors Claude Code TodoWrite/create.',
117
+ parameters: {
118
+ type: 'object',
119
+ additionalProperties: false,
120
+ required: ['title'],
121
+ properties: {
122
+ title: { type: 'string', description: 'Short imperative summary, max 2000 chars.' },
123
+ status: {
124
+ type: 'string',
125
+ enum: ['pending', 'in_progress', 'completed', 'cancelled'],
126
+ description: 'Initial status. Default pending.',
127
+ },
128
+ notes: { type: 'string', description: 'Optional free-form context.' },
129
+ },
130
+ },
131
+ }, {
132
+ name: 'task_get',
133
+ description: 'Fetch a single task record by id. Returns null when absent.',
134
+ parameters: {
135
+ type: 'object',
136
+ additionalProperties: false,
137
+ required: ['id'],
138
+ properties: { id: { type: 'string' } },
139
+ },
140
+ }, {
141
+ name: 'task_list',
142
+ description: 'List all tasks for the current session ordered by createdAt ascending.',
143
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
144
+ }, {
145
+ name: 'task_update',
146
+ description: 'Mutate status/title/notes on an existing task. Throws on unknown id. Append-only journal.',
147
+ parameters: {
148
+ type: 'object',
149
+ additionalProperties: false,
150
+ required: ['id'],
151
+ properties: {
152
+ id: { type: 'string' },
153
+ title: { type: 'string' },
154
+ status: {
155
+ type: 'string',
156
+ enum: ['pending', 'in_progress', 'completed', 'cancelled'],
157
+ },
158
+ notes: { type: 'string' },
159
+ },
160
+ },
161
+ });
162
+ // β1 T2: AskUserQuestion bridge. Returns picked answer in interactive
163
+ // mode, `[user_input_required]` envelope otherwise.
164
+ toolDefs.push({
165
+ name: 'ask_user_question',
166
+ description: 'Surface a 2-4 option modal to the operator. Interactive TTY: returns the chosen label(s). Non-TTY: returns a [user_input_required] envelope.',
167
+ parameters: {
168
+ type: 'object',
169
+ additionalProperties: false,
170
+ required: ['question', 'options'],
171
+ properties: {
172
+ question: { type: 'string', description: 'Short, scannable question. Max 1000 chars.' },
173
+ options: {
174
+ type: 'array',
175
+ items: { type: 'string', maxLength: 200 },
176
+ minItems: 2,
177
+ maxItems: 4,
178
+ },
179
+ multiSelect: { type: 'boolean' },
180
+ },
181
+ },
182
+ });
183
+ // β1 T3: Skill tool — discover + invoke locally-installed skills.
184
+ toolDefs.push({
185
+ name: 'skills_list',
186
+ description: 'List installed skills (global + workspace). Returns name+description+scope.',
187
+ parameters: {
188
+ type: 'object',
189
+ additionalProperties: false,
190
+ properties: {
191
+ scope: { type: 'string', enum: ['all', 'global', 'workspace'] },
192
+ },
193
+ },
194
+ }, {
195
+ name: 'skill',
196
+ description: 'Load a skill body by name. Workspace scope wins over global. Body capped at 32KB.',
197
+ parameters: {
198
+ type: 'object',
199
+ additionalProperties: false,
200
+ required: ['name'],
201
+ properties: { name: { type: 'string' } },
202
+ },
203
+ });
204
+ // β1 T5 → β1a r1 (gating fix, 2026-05-26): WebFetch wire-in. Schema
205
+ // mirrors the existing tool surface in
206
+ // `apps/pugi-cli/src/tools/web-fetch.ts`. SSRF guard runs inside the
207
+ // tool itself, but advertising the tool to the model when the tenant
208
+ // has not opted in is itself a privacy leak — the model could infer
209
+ // URL patterns and try to exfiltrate via the refused call's argument
210
+ // bytes. Only push the schema entry when the operator has explicitly
211
+ // enabled fetch (either via `.pugi/settings.json::web.fetch.enabled`
212
+ // or via `--allow-fetch`).
213
+ if (options.allowFetch) {
214
+ toolDefs.push({
215
+ name: 'web_fetch',
216
+ description: 'One-shot HTTP GET against an operator-supplied URL. Response is parsed to Markdown and wrapped in <untrusted-content> sentinel. Gated off by default.',
217
+ parameters: {
218
+ type: 'object',
219
+ additionalProperties: false,
220
+ required: ['url'],
221
+ properties: {
222
+ url: { type: 'string', description: 'Fully-qualified http(s) URL.' },
223
+ },
224
+ },
225
+ });
226
+ }
75
227
  if (!planMode) {
76
228
  toolDefs.push({
77
229
  name: 'write',
@@ -135,7 +287,8 @@ function requireString(obj, key) {
135
287
  return v;
136
288
  }
137
289
  export function buildExecutor(input) {
138
- const { kind, ctx, hooks, sessionId } = input;
290
+ const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch } = input;
291
+ const workspaceRoot = input.workspaceRoot ?? ctx.root;
139
292
  const planMode = kind === 'plan';
140
293
  return async ({ name, arguments: argsRaw }) => {
141
294
  if (!WIRED_TOOLS.has(name)) {
@@ -185,6 +338,21 @@ export function buildExecutor(input) {
185
338
  }
186
339
  const args = parseArgs(argsRaw);
187
340
  const dispatch = async () => {
341
+ // β1 T1/T2/T3/T5/T6: async-dispatch the new tool surface.
342
+ // task_*, skill, ask_user_question, web_fetch all live behind
343
+ // an async or async-compatible boundary.
344
+ if (name === 'task_create' || name === 'task_get' || name === 'task_list' || name === 'task_update') {
345
+ return dispatchTaskTool(name, args, { workspaceRoot, sessionId });
346
+ }
347
+ if (name === 'ask_user_question') {
348
+ return dispatchAskUser(args, { interactive: Boolean(interactive), bridge: askUserBridge });
349
+ }
350
+ if (name === 'skill' || name === 'skills_list') {
351
+ return dispatchSkillTool(name, args, { workspaceRoot });
352
+ }
353
+ if (name === 'web_fetch') {
354
+ return dispatchWebFetch(args, { ctx, allowFetch: Boolean(allowFetch) });
355
+ }
188
356
  return dispatchTool(name, args, ctx);
189
357
  };
190
358
  try {
@@ -342,4 +510,95 @@ function dispatchTool(name, args, ctx) {
342
510
  throw new Error(`unhandled tool: ${name}`);
343
511
  }
344
512
  }
513
+ /* ----------------------------- β1 dispatchers ----------------------------- */
514
+ function dispatchTaskTool(name, args, opts) {
515
+ if (!opts.sessionId) {
516
+ throw new Error(`${name}: no sessionId in scope — task ledger requires a session`);
517
+ }
518
+ const tctx = { workspaceRoot: opts.workspaceRoot, sessionId: opts.sessionId };
519
+ switch (name) {
520
+ case 'task_create': {
521
+ const title = requireString(args, 'title');
522
+ const status = optionalString(args, 'status');
523
+ const notes = optionalString(args, 'notes');
524
+ const record = taskCreate(tctx, {
525
+ title,
526
+ ...(status !== undefined ? { status: status } : {}),
527
+ ...(notes !== undefined ? { notes } : {}),
528
+ });
529
+ return JSON.stringify(record);
530
+ }
531
+ case 'task_get': {
532
+ const id = requireString(args, 'id');
533
+ const record = taskGet(tctx, id);
534
+ return record ? JSON.stringify(record) : 'null';
535
+ }
536
+ case 'task_list': {
537
+ const list = taskList(tctx);
538
+ return JSON.stringify(list);
539
+ }
540
+ case 'task_update': {
541
+ const id = requireString(args, 'id');
542
+ const title = optionalString(args, 'title');
543
+ const status = optionalString(args, 'status');
544
+ const notes = optionalString(args, 'notes');
545
+ const record = taskUpdate(tctx, {
546
+ id,
547
+ ...(title !== undefined ? { title } : {}),
548
+ ...(status !== undefined ? { status: status } : {}),
549
+ ...(notes !== undefined ? { notes } : {}),
550
+ });
551
+ return JSON.stringify(record);
552
+ }
553
+ }
554
+ }
555
+ async function dispatchAskUser(args, opts) {
556
+ const question = requireString(args, 'question');
557
+ const rawOptions = args['options'];
558
+ if (!Array.isArray(rawOptions)) {
559
+ throw new Error('ask_user_question: options must be an array');
560
+ }
561
+ const options = rawOptions.map((o, i) => {
562
+ if (typeof o !== 'string') {
563
+ throw new Error(`ask_user_question: options[${i}] must be a string`);
564
+ }
565
+ return o;
566
+ });
567
+ const multiSelect = args['multiSelect'] === true;
568
+ const result = await askUser({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, { question, options, multiSelect });
569
+ return result.envelope;
570
+ }
571
+ async function dispatchSkillTool(name, args, opts) {
572
+ if (name === 'skills_list') {
573
+ const scopeArg = optionalString(args, 'scope');
574
+ const scope = scopeArg === 'global' || scopeArg === 'workspace' ? scopeArg : 'all';
575
+ const list = skillList({ workspaceRoot: opts.workspaceRoot }, { scope });
576
+ return JSON.stringify(list);
577
+ }
578
+ // name === 'skill' (invoke).
579
+ // β1a r1 (2026-05-26): `skillInvoke` is now async — it re-verifies
580
+ // the trust manifest sha256 against the on-disk body on every call.
581
+ // Bubble up `await` so a post-install tamper surfaces as a tool
582
+ // error the model sees, not a swallowed Promise<SkillInvokeResult>.
583
+ const skName = requireString(args, 'name');
584
+ const result = await skillInvoke({ workspaceRoot: opts.workspaceRoot }, { name: skName });
585
+ return JSON.stringify(result);
586
+ }
587
+ async function dispatchWebFetch(args, opts) {
588
+ const url = requireString(args, 'url');
589
+ const result = await webFetchTool({ url }, {
590
+ settings: opts.ctx.settings,
591
+ allowFetch: opts.allowFetch,
592
+ });
593
+ return JSON.stringify(result);
594
+ }
595
+ function optionalString(obj, key) {
596
+ const v = obj[key];
597
+ if (v === undefined || v === null)
598
+ return undefined;
599
+ if (typeof v !== 'string') {
600
+ throw new Error(`tool argument "${key}" must be a string when present`);
601
+ }
602
+ return v;
603
+ }
345
604
  //# sourceMappingURL=tool-bridge.js.map