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

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.
@@ -67,6 +67,22 @@ export class AnvilEngineLoopClient {
67
67
  tools,
68
68
  maxTokens: options.maxTokens,
69
69
  temperature: options.temperature,
70
+ // β1 (audit E2): the admin-api `EngineRequestDto` accepts
71
+ // these optional fields (see `pugi-engine.controller.ts:230`
72
+ // EngineRequestDto schema). Before this fix the CLI dropped
73
+ // them, which forced the controller to fall back to legacy
74
+ // per-persona resolution + emit `command="(none)"` in its
75
+ // structured logs. `undefined` keys are stripped by
76
+ // `JSON.stringify` so the payload stays clean for fixture
77
+ // clients that exact-match the body shape.
78
+ command: options.command,
79
+ // β1a r1: `tag` is `EngineDispatchTag` object shape now —
80
+ // `JSON.stringify` serialises it as `{tag, priority?,
81
+ // budget_hint?}` matching `EngineDispatchTagDto`. Previously
82
+ // this was a bare string and the server's `IsIn` validator
83
+ // rejected every payload with HTTP 400.
84
+ tag: options.tag,
85
+ model: options.model,
70
86
  }),
71
87
  signal: controller.signal,
72
88
  });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * β1 defaults. Source of truth for the per-command budget envelope.
3
+ * The runtime is allowed to look these up directly (no need to round
4
+ * trip through settings.json when no override is in play).
5
+ */
6
+ export const beta1DefaultBudgets = {
7
+ fix: { maxTokens: 30_000, maxToolCalls: 20 },
8
+ code: { maxTokens: 30_000, maxToolCalls: 20 },
9
+ build: { maxTokens: 200_000, maxToolCalls: 30 },
10
+ plan: { maxTokens: 200_000, maxToolCalls: 8 },
11
+ explain: { maxTokens: 20_000, maxToolCalls: 5 },
12
+ review_triple: { maxTokens: 100_000, maxToolCalls: 10 },
13
+ };
14
+ /**
15
+ * Hard upper bounds. Anything above this is treated as user error
16
+ * (likely a typo or misplaced decimal) and rejected by
17
+ * `assertBudgetWithinTier`. Stops a careless settings.json edit from
18
+ * silently authorising a 100M-token run.
19
+ */
20
+ export const HARD_MAX_TOKENS = 5_000_000;
21
+ export const HARD_MAX_TOOL_CALLS = 500;
22
+ /**
23
+ * Compute the effective budget for a given command, applying:
24
+ * 1. β1 defaults
25
+ * 2. settings.json `budgets.<command>` partial overrides
26
+ * 3. task-level override (caller-provided, e.g. CLI `--max-tokens`)
27
+ *
28
+ * Throws `BudgetConfigError` when the resolved budget exceeds the
29
+ * HARD_MAX_* caps so misconfigured settings.json fails fast.
30
+ */
31
+ export function resolveBudget(command, settings, override) {
32
+ const base = beta1DefaultBudgets[command];
33
+ const settingsBudget = readSettingsBudget(settings, command);
34
+ const resolved = {
35
+ maxTokens: override?.maxTokens ??
36
+ settingsBudget?.maxTokens ??
37
+ base.maxTokens,
38
+ maxToolCalls: override?.maxToolCalls ??
39
+ settingsBudget?.maxToolCalls ??
40
+ base.maxToolCalls,
41
+ };
42
+ assertBudgetWithinTier(command, resolved);
43
+ return resolved;
44
+ }
45
+ export class BudgetConfigError extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = 'BudgetConfigError';
49
+ }
50
+ }
51
+ export function assertBudgetWithinTier(command, budget) {
52
+ if (!Number.isFinite(budget.maxTokens) || budget.maxTokens <= 0) {
53
+ throw new BudgetConfigError(`budget[${command}].maxTokens must be a positive number, got ${budget.maxTokens}`);
54
+ }
55
+ if (!Number.isFinite(budget.maxToolCalls) || budget.maxToolCalls <= 0) {
56
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls must be a positive number, got ${budget.maxToolCalls}`);
57
+ }
58
+ if (budget.maxTokens > HARD_MAX_TOKENS) {
59
+ throw new BudgetConfigError(`budget[${command}].maxTokens=${budget.maxTokens} exceeds hard cap ${HARD_MAX_TOKENS}`);
60
+ }
61
+ if (budget.maxToolCalls > HARD_MAX_TOOL_CALLS) {
62
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls=${budget.maxToolCalls} exceeds hard cap ${HARD_MAX_TOOL_CALLS}`);
63
+ }
64
+ }
65
+ /**
66
+ * Pull a settings.json budget override for the given command, with
67
+ * defensive typing. `PugiSettings` does not yet declare `budgets`
68
+ * formally (β1 is the first sprint to land it) so we cast via unknown
69
+ * and validate each field at the boundary.
70
+ */
71
+ function readSettingsBudget(settings, command) {
72
+ if (!settings)
73
+ return undefined;
74
+ const root = settings.budgets;
75
+ if (!root || typeof root !== 'object' || Array.isArray(root))
76
+ return undefined;
77
+ const map = root;
78
+ const entry = map[command];
79
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
80
+ return undefined;
81
+ const e = entry;
82
+ const out = {};
83
+ if (typeof e['maxTokens'] === 'number')
84
+ out.maxTokens = e['maxTokens'];
85
+ if (typeof e['maxToolCalls'] === 'number')
86
+ out.maxToolCalls = e['maxToolCalls'];
87
+ return out;
88
+ }
89
+ //# sourceMappingURL=budgets.js.map
@@ -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
@@ -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