@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.
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +16 -0
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/native-pugi.js +112 -12
- package/dist/core/engine/prompts.js +8 -0
- package/dist/core/engine/tool-bridge.js +267 -8
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +719 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +72 -1
- package/dist/core/repl/slash-commands.js +41 -0
- package/dist/core/settings.js +28 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/runtime/cli.js +366 -14
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/lsp.js +206 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/tools/apply-patch.js +495 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +26 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/conversation-pane.js +1 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +169 -10
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +18 -5
- package/dist/tui/splash.js +1 -1
- package/dist/tui/update-banner.js +1 -1
- package/docs/examples/codegraph.mcp.json +10 -0
- 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 {
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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({
|
|
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
|
-
|
|
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([
|
|
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
|
|
30
|
-
* (
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
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([
|
|
35
|
-
|
|
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
|