@warble/claude-agent-sdk 0.4.0

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.
@@ -0,0 +1,2844 @@
1
+ // src/error.ts
2
+ var DispatchError = class extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "DispatchError";
6
+ }
7
+ };
8
+
9
+ // src/codegen.ts
10
+ function ident(verb) {
11
+ const base = verb.replace(/[^A-Za-z0-9_$]/g, "_");
12
+ return /^[0-9]/.test(base) ? `_${base}` : base;
13
+ }
14
+ function json(value) {
15
+ return JSON.stringify(value, null, 2);
16
+ }
17
+ var RUN_RESULT_TYPE = "{ finalText: string; trace: Trace; htmlPath: string | null; denials: Denial[]; renderDegraded: { reason: string } | null }";
18
+ function runBody(fn) {
19
+ return ` const cwd = ${fn}_options.cwd ?? process.cwd();
20
+ const gate = ${fn}_meta.render;
21
+ const writeScope = gate.kind === "realize" && gate.flavor === "prompt" ? gate.scope : null;
22
+ const { canUseTool, denials, hooks } = makeReadOnlyGuard({
23
+ readOnly: ${fn}_meta.readOnly,
24
+ writeScope,
25
+ cwd,
26
+ setupScope: ${fn}_meta.setupScope,
27
+ });
28
+
29
+ // Read never reaches \`canUseTool\` for an in-cwd path in the real SDK (see guardrails.ts in the
30
+ // warble repo); this hook is the live enforcement point for the +Setup dotenv-read gate's Read
31
+ // side. Mirrors run.ts's wiring exactly \u2014 merge, don't clobber, any hooks already on the options.
32
+ const messages: SDKMessage[] = [];
33
+ for await (const m of query({
34
+ prompt: question,
35
+ options: {
36
+ ...${fn}_options,
37
+ canUseTool,
38
+ hooks: { ...${fn}_options.hooks, PreToolUse: [...(${fn}_options.hooks?.PreToolUse ?? []), ...hooks] },
39
+ },
40
+ })) {
41
+ messages.push(m);
42
+ }
43
+
44
+ const result = messages.find((m): m is Extract<SDKMessage, { type: "result" }> => m.type === "result");
45
+ if (!result || result.subtype !== "success") {
46
+ throw new Error(\`agent run failed: \${result ? result.subtype : "no result message"}\`);
47
+ }
48
+ const finalText = result.result;
49
+ const trace = aggregateTrace(
50
+ messages,
51
+ { target: ${fn}_meta.target, verb: ${fn}_meta.verb, model: ${fn}_meta.model, split: ${fn}_meta.split },
52
+ denials,
53
+ );
54
+
55
+ let htmlPath: string | null = null;
56
+ let renderDegraded: { reason: string } | null = null;
57
+ if (gate.kind === "realize" && gate.flavor === "programmatic" && opts.outDir) {
58
+ const out = join(opts.outDir, "dashboard.html");
59
+ try {
60
+ renderEnvelope(finalText, out, { warbleBin: opts.warbleBin ?? "warble", ...(opts.title ? { title: opts.title } : {}) });
61
+ htmlPath = out;
62
+ } catch (err) {
63
+ // best-effort render_contract: degrade to the agent's own text instead of failing the whole
64
+ // run (capability-model.md \u2014 only safety-critical/required capabilities never silently
65
+ // degrade). \`onFailure\` absent/"fail" preserves the prior hard-fail behavior exactly.
66
+ if (gate.onFailure !== "degrade") throw err;
67
+ renderDegraded = { reason: err instanceof Error ? err.message : String(err) };
68
+ }
69
+ }
70
+ return { finalText, trace, htmlPath, denials, renderDegraded };`;
71
+ }
72
+ function componentBlock(fn, verb, options, meta) {
73
+ return `// ---- component: ${verb} ----
74
+ const ${fn}_options = ${json(options)} satisfies Options;
75
+ const ${fn}_meta: EmittedMeta = ${json(meta)};
76
+
77
+ /** Run the \`${verb}\` agent against the live Agent SDK loop. */
78
+ export async function ${fn}(question: string, opts: RunOptions = {}): Promise<RunResult> {
79
+ ${runBody(fn)}
80
+ }`;
81
+ }
82
+ var THIN_IMPORTS = `import { join } from "node:path";
83
+ import { query, type Options, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
84
+ import {
85
+ makeReadOnlyGuard,
86
+ aggregateTrace,
87
+ renderEnvelope,
88
+ type Trace,
89
+ type Denial,
90
+ } from "@warble/claude-agent-sdk";`;
91
+ var STANDALONE_IMPORTS = `import { join, sep } from "node:path";
92
+ import { spawnSync } from "node:child_process";
93
+ import { mkdtempSync, writeFileSync } from "node:fs";
94
+ import { tmpdir } from "node:os";
95
+ import { query, type Options, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";`;
96
+ var STANDALONE_HELPERS = `interface Denial { tool: string; reason: string; command?: string }
97
+ interface Trace {
98
+ target: string; verb: string; model: string; split: boolean;
99
+ run: { total_cost_usd: number; duration_ms: number; duration_api_ms: number; num_turns: number } | null;
100
+ modelUsage: Record<string, unknown>;
101
+ steps: { model: string; parent_tool_use_id: string | null; usage: unknown }[];
102
+ denials: Denial[];
103
+ }
104
+
105
+ const DESTRUCTIVE = /\\b(rm|sudo|dd|mkfs|shutdown|reboot|kill|chmod|chown|mv|cp)\\b/;
106
+ const REDIRECTION = /(^|[^>])>>?[^>]/;
107
+ // Kept byte-identical to guardrails.ts's DOTENV_READER_COMMANDS/DOTENV_PATH pair (see that file's doc
108
+ // comment for the incident this closes) \u2014 checked FIRST and unconditionally in the Bash branch below,
109
+ // exactly like canonical, never gated behind any config field. tests/guard-drift.test.ts asserts this
110
+ // inlined guard stays behaviorally equivalent to guardrails.ts on the surface standalone mode actually
111
+ // supports (setupScope always null here \u2014 see the wall-hit above).
112
+ const DOTENV_READER_COMMANDS = /\\b(cat|head|tail|less|more|od|xxd|strings|grep|awk|sed)\\b/;
113
+ const DOTENV_PATH = /(^|[\\s"'\\/=])\\.env(\\.[\\w.-]+)?(?=$|[\\s"'\\/])/;
114
+
115
+ function referencesDotenvPath(text: string): boolean {
116
+ return DOTENV_PATH.test(text);
117
+ }
118
+
119
+ function makeReadOnlyGuard(cfg: {
120
+ readOnly: boolean;
121
+ writeScope: string | null;
122
+ cwd: string;
123
+ mutation?: { mustDryRun: boolean; approvalRequired: boolean };
124
+ // \`emitAgentModule\` wall-hits before generating any component code if a setup-scoped component is
125
+ // combined with --standalone (see codegen.ts), so this is always null here in practice \u2014 the field
126
+ // only exists so this inlined guard's call signature matches the thin-mode \`makeReadOnlyGuard\`
127
+ // import that runBody() (shared between both modes) calls. This guard's Bash branch DOES carry the
128
+ // unconditional dotenv-read denylist (see DOTENV_READER_COMMANDS/DOTENV_PATH above) \u2014 that applies to
129
+ // every component, not just +Setup ones, so it stays in sync here. What it deliberately does NOT
130
+ // carry is any setupScope-aware widening (Bash beyond \`wren\`, Write/Edit scoped to a project root, a
131
+ // Read-side PreToolUse hook) \u2014 the wall-hit above refuses --standalone for a setup-scoped component
132
+ // rather than hand-syncing that logic and risking exactly the copy-drift this fix exists to close.
133
+ // tests/guard-drift.test.ts is the tripwire: it fails if this copy and guardrails.ts::makeReadOnlyGuard
134
+ // diverge on the setupScope == null surface, or if guardrails.ts grows new reachable behavior on that
135
+ // surface that this copy doesn't (yet) have.
136
+ setupScope?: string | null;
137
+ }) {
138
+ const denials: Denial[] = [];
139
+ const hooks: never[] = [];
140
+ const canUseTool = async (toolName: string, input: Record<string, unknown>) => {
141
+ if (toolName === "Read" || toolName === "Task" || toolName === "TodoWrite") {
142
+ return { behavior: "allow" as const, updatedInput: input };
143
+ }
144
+ if (toolName === "Bash") {
145
+ const command = typeof input.command === "string" ? input.command : "";
146
+ // Dotenv-read pair: checked FIRST and unconditionally, before DESTRUCTIVE/REDIRECTION and the
147
+ // wren-only check below \u2014 mirrors guardrails.ts::makeReadOnlyGuard exactly (see that file's
148
+ // comment; standalone mode never has setupScope set, but the same compound-command bypass
149
+ // guardrails.ts closes here applies to every component, not just +Setup ones).
150
+ if (DOTENV_READER_COMMANDS.test(command) && referencesDotenvPath(command)) {
151
+ const reason =
152
+ "reading a dotenv file's contents is blocked by the read_only_execution guardrail; the " +
153
+ "setup credential design writes an empty .env template and is never meant to read it back.";
154
+ denials.push({ tool: "Bash", reason, command });
155
+ return { behavior: "deny" as const, message: reason };
156
+ }
157
+ if (DESTRUCTIVE.test(command) || REDIRECTION.test(command)) {
158
+ const reason =
159
+ "destructive or file-writing bash is blocked by the read_only_execution guardrail; " +
160
+ "all data access must go through the read-only \`wren\` CLI.";
161
+ denials.push({ tool: "Bash", reason, command });
162
+ return { behavior: "deny" as const, message: reason };
163
+ }
164
+ if (command.trim().split(/\\s+/)[0] !== "wren") {
165
+ const reason =
166
+ "only \`wren\` CLI invocations are permitted (data access goes through the semantic " +
167
+ "layer); this command is blocked by the read_only_execution guardrail.";
168
+ denials.push({ tool: "Bash", reason, command });
169
+ return { behavior: "deny" as const, message: reason };
170
+ }
171
+ return { behavior: "allow" as const, updatedInput: input };
172
+ }
173
+ if (toolName === "Write" || toolName === "Edit") {
174
+ if (cfg.mutation) {
175
+ const gate = cfg.mutation.approvalRequired ? "human approval" : "the must_dry_run gate";
176
+ const reason = \`\${toolName} is the gated apply of a mutating component and requires \${gate} to clear first; that approval is borrowed from the SDK embedder's own canUseTool/approval channel, which this guard does not provide, so it denies by default (fail-closed).\`;
177
+ denials.push({ tool: toolName, reason });
178
+ return { behavior: "deny" as const, message: reason };
179
+ }
180
+ if (cfg.writeScope) {
181
+ const target = typeof input.file_path === "string" ? input.file_path : "";
182
+ const abs = join(cfg.cwd, target);
183
+ // Path-boundary-safe containment (mirrors guardrails.ts::withinScope): an exact match or a
184
+ // real separator boundary \u2014 never a bare prefix, which would admit a sibling like models-export/.
185
+ const scopeAbs = join(cfg.cwd, cfg.writeScope);
186
+ if (abs === scopeAbs || abs.startsWith(scopeAbs.endsWith(sep) ? scopeAbs : scopeAbs + sep)) return { behavior: "allow" as const, updatedInput: input };
187
+ const reason = \`write to '\${target}' is outside the permitted artifact scope '\${cfg.writeScope}'.\`;
188
+ denials.push({ tool: toolName, reason, command: target });
189
+ return { behavior: "deny" as const, message: reason };
190
+ }
191
+ const reason =
192
+ \`\${toolName} is blocked: this component is read-only (programmatic render flavor keeps the \` +
193
+ \`agent from writing files; the dispatcher renders the dashboard from your envelope).\`;
194
+ denials.push({ tool: toolName, reason });
195
+ return { behavior: "deny" as const, message: reason };
196
+ }
197
+ const reason = \`tool '\${toolName}' is not permitted for this component.\`;
198
+ denials.push({ tool: toolName, reason });
199
+ return { behavior: "deny" as const, message: reason };
200
+ };
201
+ return { canUseTool, denials, hooks };
202
+ }
203
+
204
+ function aggregateTrace(
205
+ messages: readonly SDKMessage[],
206
+ meta: { target: string; verb: string; model: string; split: boolean },
207
+ denials: Denial[],
208
+ ): Trace {
209
+ const steps = messages
210
+ .filter((m): m is Extract<SDKMessage, { type: "assistant" }> => m.type === "assistant")
211
+ .map((m) => ({ model: m.message.model, parent_tool_use_id: m.parent_tool_use_id, usage: m.message.usage }));
212
+ const result = messages.find((m): m is Extract<SDKMessage, { type: "result" }> => m.type === "result");
213
+ const run = result === undefined ? null : {
214
+ total_cost_usd: result.total_cost_usd, duration_ms: result.duration_ms,
215
+ duration_api_ms: result.duration_api_ms, num_turns: result.num_turns,
216
+ };
217
+ return {
218
+ target: meta.target, verb: meta.verb, model: meta.model, split: meta.split, run,
219
+ modelUsage: (result?.modelUsage ?? {}) as Record<string, unknown>, steps, denials,
220
+ };
221
+ }
222
+
223
+ function renderEnvelope(finalText: string, outPath: string, opts: { warbleBin: string; title?: string }): void {
224
+ const dir = mkdtempSync(join(tmpdir(), "warble-emit-"));
225
+ const envelopePath = join(dir, "envelope.txt");
226
+ writeFileSync(envelopePath, finalText, "utf8");
227
+ const args = ["render", envelopePath, "--out", outPath];
228
+ if (opts.title) args.push("--title", opts.title);
229
+ const proc = spawnSync(opts.warbleBin, args, { encoding: "utf8" });
230
+ if (proc.error) {
231
+ const err = proc.error as NodeJS.ErrnoException;
232
+ const base = \`failed to run '\${opts.warbleBin} render': \${err.message}\`;
233
+ if (err.code === "ENOENT") {
234
+ throw new Error(
235
+ \`\${base}\\nThe 'warble' binary was not found. Install it with 'cargo install warble-cli' \` +
236
+ \`(requires a Rust toolchain; installs from crates.io), or set the 'warbleBin' option \` +
237
+ \`(CLI: --warble-bin <path>) to point at an existing 'warble' binary.\`,
238
+ );
239
+ }
240
+ throw new Error(\`\${base} (set the 'warbleBin' option, or pass --warble-bin <path> on the CLI, to point at a different binary)\`);
241
+ }
242
+ if (proc.status !== 0) throw new Error(\`warble render exited \${proc.status}: \${proc.stderr?.trim() ?? ""}\`);
243
+ }`;
244
+ var PREAMBLE_TYPES = `export interface RunOptions { outDir?: string; warbleBin?: string; title?: string }
245
+ export type RunResult = ${RUN_RESULT_TYPE};
246
+
247
+ interface EmittedMeta {
248
+ target: string; verb: string; model: string; split: boolean; readOnly: boolean;
249
+ render: {
250
+ kind: "realize" | "degrade" | "none"; scope: string | null; flavor: "programmatic" | "prompt" | null;
251
+ onFailure?: "degrade" | "fail";
252
+ };
253
+ setupScope: string | null;
254
+ }`;
255
+ function emitAgentModule(prepared, opts = {}) {
256
+ const standalone = opts.standalone ?? false;
257
+ const header = [
258
+ "// Generated by `warble-agent-sdk emit` \u2014 do not edit by hand.",
259
+ `// Target: ${prepared.target}. Regenerate from the IR instead of editing.`,
260
+ standalone ? "// Mode: standalone (runtime helpers inlined; only @anthropic-ai/claude-agent-sdk + the `warble` binary needed)." : "// Mode: thin (imports runtime helpers from @warble/claude-agent-sdk)."
261
+ ].join("\n");
262
+ if (standalone) {
263
+ const setupScoped = prepared.components.filter((c) => c.plan.meta.setupScope != null);
264
+ if (setupScoped.length > 0) {
265
+ const verbs = setupScoped.map((c) => c.node.verb).join(", ");
266
+ throw new DispatchError(
267
+ `emit --standalone does not support setup-scoped component(s) [${verbs}] (wall-hit): the inlined standalone guard has no dotenv-read Read hook and no setupScope-aware Bash/Write widening, so a standalone-ejected setup agent would have zero protection against the dotenv-read gap that guardrails.ts's PreToolUse hook closes for the thin (default) mode. Emit without --standalone for these component(s), or omit them from this IR.`
268
+ );
269
+ }
270
+ }
271
+ const blocks = prepared.components.map((c) => {
272
+ const fn = ident(c.node.verb);
273
+ const meta = {
274
+ target: c.plan.meta.target,
275
+ verb: c.plan.meta.verb,
276
+ model: c.plan.meta.model,
277
+ split: c.plan.meta.split,
278
+ readOnly: c.plan.meta.readOnly,
279
+ render: c.plan.meta.render,
280
+ setupScope: c.plan.meta.setupScope
281
+ };
282
+ return componentBlock(fn, c.node.verb, c.plan.options, meta);
283
+ });
284
+ return [
285
+ header,
286
+ "",
287
+ standalone ? STANDALONE_IMPORTS : THIN_IMPORTS,
288
+ "",
289
+ PREAMBLE_TYPES,
290
+ ...standalone ? ["", STANDALONE_HELPERS] : [],
291
+ "",
292
+ ...blocks,
293
+ ""
294
+ ].join("\n");
295
+ }
296
+
297
+ // src/ir.ts
298
+ var REALIZATION_KINDS = ["skill", "tool", "gated-tool"];
299
+ var COMPONENT_TYPES = [
300
+ "analytical",
301
+ "assertive",
302
+ "mutating",
303
+ "constitutive",
304
+ "orchestrating"
305
+ ];
306
+ var TRIGGER_KINDS = ["one_shot", "scheduled", "event"];
307
+ var OUTCOME_KINDS = ["none", "assertion", "mutation", "dispatch"];
308
+ var SUPPORTED_IR_VERSIONS = ["0.6"];
309
+ function assertSupportedIrVersion(version) {
310
+ if (!SUPPORTED_IR_VERSIONS.includes(version)) {
311
+ throw new DispatchError(
312
+ `unsupported warble_ir_version '${version}' (this back-end understands: ${SUPPORTED_IR_VERSIONS.join(", ")})`
313
+ );
314
+ }
315
+ }
316
+ function isObject(value) {
317
+ return typeof value === "object" && value !== null && !Array.isArray(value);
318
+ }
319
+ function fail(message) {
320
+ throw new DispatchError(`invalid IR: ${message}`);
321
+ }
322
+ function requireObject(value, at) {
323
+ if (!isObject(value)) fail(`${at} must be an object`);
324
+ return value;
325
+ }
326
+ function requireString(obj, key, at) {
327
+ const value = obj[key];
328
+ if (typeof value !== "string") fail(`${at}.${key} must be a string`);
329
+ return value;
330
+ }
331
+ function requireBool(obj, key, at) {
332
+ const value = obj[key];
333
+ if (typeof value !== "boolean") fail(`${at}.${key} must be a boolean`);
334
+ return value;
335
+ }
336
+ function optString(obj, key) {
337
+ const value = obj[key];
338
+ if (value === void 0 || value === null) return null;
339
+ if (typeof value !== "string") fail(`${key} must be a string when present`);
340
+ return value;
341
+ }
342
+ function optStringU(obj, key) {
343
+ const value = obj[key];
344
+ if (value === void 0 || value === null) return void 0;
345
+ if (typeof value !== "string") fail(`${key} must be a string when present`);
346
+ return value;
347
+ }
348
+ function optStringArrayU(obj, key, at) {
349
+ if (obj[key] === void 0 || obj[key] === null) return void 0;
350
+ return requireArray(obj, key, at).map((v, i) => {
351
+ if (typeof v !== "string") fail(`${at}.${key}[${i}] must be a string`);
352
+ return v;
353
+ });
354
+ }
355
+ function boolWithDefault(obj, key, at) {
356
+ const value = obj[key];
357
+ if (value === void 0) return false;
358
+ if (typeof value !== "boolean") fail(`${at}.${key} must be a boolean when present`);
359
+ return value;
360
+ }
361
+ function requireArray(obj, key, at) {
362
+ const value = obj[key];
363
+ if (!Array.isArray(value)) fail(`${at}.${key} must be an array`);
364
+ return value;
365
+ }
366
+ function stringArray(obj, key, at) {
367
+ if (obj[key] === void 0) return [];
368
+ return requireArray(obj, key, at).map((v, i) => {
369
+ if (typeof v !== "string") fail(`${at}.${key}[${i}] must be a string`);
370
+ return v;
371
+ });
372
+ }
373
+ function requireEnum(obj, key, at, allowed) {
374
+ const value = requireString(obj, key, at);
375
+ if (!allowed.includes(value)) {
376
+ fail(`${at}.${key} '${value}' is not one of: ${allowed.join(", ")}`);
377
+ }
378
+ return value;
379
+ }
380
+ function parseContextBinding(value, at) {
381
+ const obj = requireObject(value, at);
382
+ return {
383
+ project: requireString(obj, "project", at),
384
+ binding_mode: requireString(obj, "binding_mode", at),
385
+ // v0.3 fine-grained resolved binding; carried opaquely (not consumed by this back-end).
386
+ resolved: obj["resolved"]
387
+ };
388
+ }
389
+ function parseChecks(obj, at) {
390
+ if (obj["checks"] === void 0) return [];
391
+ return requireArray(obj, "checks", at).map((c, i) => {
392
+ const check = requireObject(c, `${at}.checks[${i}]`);
393
+ return {
394
+ predicate: requireString(check, "predicate", `${at}.checks[${i}]`),
395
+ outcome: requireString(check, "outcome", `${at}.checks[${i}]`)
396
+ };
397
+ });
398
+ }
399
+ function parseWhenGuard(value, at) {
400
+ const obj = requireObject(value, at);
401
+ return {
402
+ guard: requireString(obj, "guard", at),
403
+ target: requireString(obj, "target", at)
404
+ };
405
+ }
406
+ function parseLlmCall(value, at) {
407
+ const obj = requireObject(value, at);
408
+ const whenRaw = obj["when"];
409
+ return {
410
+ name: requireString(obj, "name", at),
411
+ tier: requireString(obj, "tier", at),
412
+ consumes: stringArray(obj, "consumes", at),
413
+ produces: optString(obj, "produces"),
414
+ prompt: requireString(obj, "prompt", at),
415
+ conditional: boolWithDefault(obj, "conditional", at),
416
+ when: whenRaw === void 0 || whenRaw === null ? null : parseWhenGuard(whenRaw, `${at}.when`)
417
+ };
418
+ }
419
+ function parseGuardrail(value, at) {
420
+ const obj = requireObject(value, at);
421
+ return {
422
+ name: requireString(obj, "name", at),
423
+ locked: requireBool(obj, "locked", at),
424
+ scope: optString(obj, "scope"),
425
+ threshold: obj["threshold"]
426
+ };
427
+ }
428
+ function parsePrecondition(value, at) {
429
+ const obj = requireObject(value, at);
430
+ const argsRaw = obj["args"];
431
+ const args = argsRaw === void 0 || argsRaw === null ? void 0 : requireObject(argsRaw, `${at}.args`);
432
+ return { predicate: requireString(obj, "predicate", at), args };
433
+ }
434
+ function parseParamSpec(value, at) {
435
+ const obj = requireObject(value, at);
436
+ return {
437
+ name: requireString(obj, "name", at),
438
+ bind: optStringU(obj, "bind"),
439
+ source: optStringU(obj, "source"),
440
+ default: obj["default"]
441
+ };
442
+ }
443
+ function parseEvalSpec(value, at) {
444
+ const obj = requireObject(value, at);
445
+ return {
446
+ template_ref: requireString(obj, "template_ref", at),
447
+ metrics: stringArray(obj, "metrics", at)
448
+ };
449
+ }
450
+ function preconditionArray(obj, key, at) {
451
+ if (obj[key] === void 0) return [];
452
+ return requireArray(obj, key, at).map((v, i) => parsePrecondition(v, `${at}.${key}[${i}]`));
453
+ }
454
+ function paramArray(obj, key, at) {
455
+ if (obj[key] === void 0) return [];
456
+ return requireArray(obj, key, at).map((v, i) => parseParamSpec(v, `${at}.${key}[${i}]`));
457
+ }
458
+ function parseRenderBlock(value, at) {
459
+ const obj = requireObject(value, at);
460
+ const fieldsRaw = obj["fields"];
461
+ const fields = {};
462
+ if (fieldsRaw !== void 0) {
463
+ const fieldsObj = requireObject(fieldsRaw, `${at}.fields`);
464
+ for (const [k, v] of Object.entries(fieldsObj)) {
465
+ if (typeof v !== "string") fail(`${at}.fields.${k} must be a string`);
466
+ fields[k] = v;
467
+ }
468
+ }
469
+ return { type: requireString(obj, "type", at), fields };
470
+ }
471
+ function parseOutcome(value, at) {
472
+ const obj = requireObject(value, at);
473
+ return {
474
+ kind: requireEnum(obj, "kind", at, OUTCOME_KINDS),
475
+ verdict_type: optStringU(obj, "verdict_type"),
476
+ emits: optStringArrayU(obj, "emits", at),
477
+ target: optStringU(obj, "target"),
478
+ change_type: optStringU(obj, "change_type"),
479
+ routable_scope: obj["routable_scope"]
480
+ };
481
+ }
482
+ function parseEffect(value, at) {
483
+ const obj = requireObject(value, at);
484
+ const blocks = obj["render_blocks"] === void 0 ? [] : requireArray(obj, "render_blocks", at).map(
485
+ (b, i) => parseRenderBlock(b, `${at}.render_blocks[${i}]`)
486
+ );
487
+ return {
488
+ render_blocks: blocks,
489
+ outcome: parseOutcome(obj["outcome"], `${at}.outcome`)
490
+ };
491
+ }
492
+ function parseComponent(value, at) {
493
+ const obj = requireObject(value, at);
494
+ const precondition = requireObject(obj["precondition_result"], `${at}.precondition_result`);
495
+ const trigger = requireObject(obj["trigger"], `${at}.trigger`);
496
+ return {
497
+ id: requireString(obj, "id", at),
498
+ verb: requireString(obj, "verb", at),
499
+ type: requireEnum(obj, "type", at, COMPONENT_TYPES),
500
+ realization_kind: requireEnum(obj, "realization_kind", at, REALIZATION_KINDS),
501
+ context_binding: parseContextBinding(obj["context_binding"], `${at}.context_binding`),
502
+ precondition_result: {
503
+ status: requireString(precondition, "status", `${at}.precondition_result`),
504
+ checks: parseChecks(precondition, `${at}.precondition_result`)
505
+ },
506
+ prompt_fragment: requireString(obj, "prompt_fragment", at),
507
+ llm_calls: requireArray(obj, "llm_calls", at).map(
508
+ (c, i) => parseLlmCall(c, `${at}.llm_calls[${i}]`)
509
+ ),
510
+ guardrails: requireArray(obj, "guardrails", at).map(
511
+ (g, i) => parseGuardrail(g, `${at}.guardrails[${i}]`)
512
+ ),
513
+ trigger: { kind: requireEnum(trigger, "kind", `${at}.trigger`, TRIGGER_KINDS) },
514
+ required_capabilities: stringArray(obj, "required_capabilities", at),
515
+ borrowed_actions: stringArray(obj, "borrowed_actions", at),
516
+ eval_ref: requireString(obj, "eval_ref", at),
517
+ effect: parseEffect(obj["effect"], `${at}.effect`),
518
+ context_requirements: stringArray(obj, "context_requirements", at),
519
+ context_precondition: preconditionArray(obj, "context_precondition", at),
520
+ params: paramArray(obj, "params", at),
521
+ eval: obj["eval"] === void 0 || obj["eval"] === null ? null : parseEvalSpec(obj["eval"], `${at}.eval`),
522
+ brief: optStringU(obj, "brief")
523
+ };
524
+ }
525
+ function parseIr(json2) {
526
+ let root;
527
+ try {
528
+ root = JSON.parse(json2);
529
+ } catch (e) {
530
+ fail(`not valid JSON: ${e.message}`);
531
+ }
532
+ const obj = requireObject(root, "<root>");
533
+ const version = requireString(obj, "warble_ir_version", "<root>");
534
+ assertSupportedIrVersion(version);
535
+ const configRaw = obj["config"];
536
+ const config = {};
537
+ if (configRaw !== void 0) {
538
+ requireObject(configRaw, "config");
539
+ }
540
+ return {
541
+ warble_ir_version: version,
542
+ profile: requireString(obj, "profile", "<root>"),
543
+ context_binding: parseContextBinding(obj["context_binding"], "context_binding"),
544
+ config,
545
+ components: requireArray(obj, "components", "<root>").map(
546
+ (c, i) => parseComponent(c, `components[${i}]`)
547
+ )
548
+ };
549
+ }
550
+ function distinctTiers(calls) {
551
+ const seen = /* @__PURE__ */ new Set();
552
+ const out = [];
553
+ for (const call of calls) {
554
+ if (!seen.has(call.tier)) {
555
+ seen.add(call.tier);
556
+ out.push(call.tier);
557
+ }
558
+ }
559
+ return out;
560
+ }
561
+
562
+ // src/models.ts
563
+ import { parse as parseYaml } from "yaml";
564
+ var STRONG_TIER = "strong";
565
+ var CHEAP_TIER = "cheap";
566
+ var ORCHESTRATOR_TIER = "orchestrator";
567
+ var ANTHROPIC_PROVIDER = "anthropic";
568
+ var OPENAI_COMPAT_PROVIDER = "openai_compat";
569
+ function anthropicBinding(model) {
570
+ return { provider: ANTHROPIC_PROVIDER, endpoint: null, model };
571
+ }
572
+ var ModelConfig = class _ModelConfig {
573
+ /** `[tier name, binding]` in declaration order (earliest = strongest). */
574
+ tiers;
575
+ constructor(tiers) {
576
+ this.tiers = tiers;
577
+ }
578
+ /** The Agent SDK defaults, matching the file target: strong→opus, cheap→haiku, orchestrator→sonnet. */
579
+ static default() {
580
+ return new _ModelConfig([
581
+ [STRONG_TIER, anthropicBinding("opus")],
582
+ [CHEAP_TIER, anthropicBinding("haiku")],
583
+ [ORCHESTRATOR_TIER, anthropicBinding("sonnet")]
584
+ ]);
585
+ }
586
+ /**
587
+ * Build from the inline `--strong/--cheap/--orchestrator` flags. Inline flags are always
588
+ * Anthropic-provider aliases — provider/endpoint routing is `--models-config` only, so a non-alias
589
+ * inline flag still loud-fails on the SDK split path (unchanged behavior).
590
+ */
591
+ static fromFlags(strong, cheap, orchestrator) {
592
+ return new _ModelConfig([
593
+ [STRONG_TIER, anthropicBinding(strong)],
594
+ [CHEAP_TIER, anthropicBinding(cheap)],
595
+ [ORCHESTRATOR_TIER, anthropicBinding(orchestrator)]
596
+ ]);
597
+ }
598
+ /**
599
+ * Parse a `--models-config` YAML document — the same shape the file target accepts. A tier value is
600
+ * EITHER a bare model-alias string (Anthropic shorthand) OR a `{ provider, endpoint?, model }` map:
601
+ *
602
+ * ```yaml
603
+ * tiers:
604
+ * strong: opus # shorthand ⇒ provider: anthropic
605
+ * cheap: # structured binding (docs/spec/capability-model.md §7.2)
606
+ * provider: openai_compat
607
+ * endpoint: http://localhost:11434/v1
608
+ * model: qwen2.5
609
+ * orchestrator: sonnet # reserved: the per-step-tier driver
610
+ * ```
611
+ */
612
+ static fromYaml(text) {
613
+ let doc;
614
+ try {
615
+ doc = parseYaml(text);
616
+ } catch (e) {
617
+ throw new DispatchError(`invalid models config: ${e.message}`);
618
+ }
619
+ if (typeof doc !== "object" || doc === null) {
620
+ throw new DispatchError("invalid models config: expected a mapping with a `tiers:` key");
621
+ }
622
+ const tiersRaw = doc["tiers"];
623
+ if (typeof tiersRaw !== "object" || tiersRaw === null || Array.isArray(tiersRaw)) {
624
+ throw new DispatchError("models config: `tiers` must be a mapping");
625
+ }
626
+ const tiers = [];
627
+ for (const [name, value] of Object.entries(tiersRaw)) {
628
+ tiers.push([name, parseTierValue(name, value)]);
629
+ }
630
+ if (tiers.length === 0) {
631
+ throw new DispatchError("models config: `tiers` must not be empty");
632
+ }
633
+ return new _ModelConfig(tiers);
634
+ }
635
+ bindingFor(tier) {
636
+ return this.tiers.find(([name]) => name === tier)?.[1];
637
+ }
638
+ /** Priority rank of a tier (declaration order); unknown tiers rank last. */
639
+ rank(tier) {
640
+ const idx = this.tiers.findIndex(([name]) => name === tier);
641
+ return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
642
+ }
643
+ tierNames() {
644
+ return this.tiers.map(([name]) => name).join(", ");
645
+ }
646
+ /** The model a tier maps to, or a loud-fail naming the undefined tier. */
647
+ require(tier) {
648
+ return this.binding(tier).model;
649
+ }
650
+ /**
651
+ * The full `{provider, endpoint, model}` binding a tier maps to (see docs/spec/capability-model.md
652
+ * §7.2), or a loud-fail.
653
+ * The per-step provider router (route.ts) reads this to send a step cloud-vs-local.
654
+ */
655
+ binding(tier) {
656
+ const b = this.bindingFor(tier);
657
+ if (b === void 0) {
658
+ throw new DispatchError(
659
+ `tier '${tier}' has no model binding \u2014 define it in --models-config or via --strong/--cheap (known tiers: ${this.tierNames()})`
660
+ );
661
+ }
662
+ return b;
663
+ }
664
+ /** The model for the reserved `orchestrator` tier, or a loud-fail if a config omitted it. */
665
+ orchestrator() {
666
+ return this.require(ORCHESTRATOR_TIER);
667
+ }
668
+ /** The model for a single collapsed call: the strongest (lowest-rank) tier among the calls. */
669
+ collapsedModel(calls) {
670
+ if (calls.length === 0) {
671
+ throw new DispatchError("component has no llm_calls; cannot select a model");
672
+ }
673
+ let strongest = calls[0];
674
+ for (const call of calls) {
675
+ if (this.rank(call.tier) < this.rank(strongest.tier)) strongest = call;
676
+ }
677
+ return this.require(strongest.tier);
678
+ }
679
+ /** Validate every step tier in the IR maps to a model (front-loaded so dispatch is infallible). */
680
+ validate(ir) {
681
+ const checked = /* @__PURE__ */ new Set();
682
+ for (const node of ir.components) {
683
+ for (const call of node.llm_calls) {
684
+ if (!checked.has(call.tier)) {
685
+ checked.add(call.tier);
686
+ this.require(call.tier);
687
+ }
688
+ }
689
+ }
690
+ }
691
+ };
692
+ function parseTierValue(name, value) {
693
+ if (typeof value === "string") {
694
+ return anthropicBinding(value);
695
+ }
696
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
697
+ throw new DispatchError(
698
+ `models config: tier '${name}' must be a model-alias string or a {provider, endpoint?, model} map`
699
+ );
700
+ }
701
+ const map = value;
702
+ const model = map["model"];
703
+ if (typeof model !== "string") {
704
+ throw new DispatchError(`models config: tier '${name}' map is missing a string \`model\``);
705
+ }
706
+ const providerRaw = map["provider"];
707
+ let provider = ANTHROPIC_PROVIDER;
708
+ if (providerRaw !== void 0) {
709
+ if (typeof providerRaw !== "string") {
710
+ throw new DispatchError(`models config: tier '${name}' has a non-string \`provider\``);
711
+ }
712
+ provider = providerRaw;
713
+ }
714
+ const endpointRaw = map["endpoint"];
715
+ const endpoint = typeof endpointRaw === "string" ? endpointRaw : null;
716
+ if (provider === OPENAI_COMPAT_PROVIDER && endpoint === null) {
717
+ throw new DispatchError(
718
+ `models config: tier '${name}' uses provider openai_compat but has no \`endpoint\``
719
+ );
720
+ }
721
+ return { provider, endpoint, model };
722
+ }
723
+
724
+ // src/route.ts
725
+ function resolveStagedSteps(node, models) {
726
+ return node.llm_calls.map((call) => {
727
+ const binding = models.binding(call.tier);
728
+ return {
729
+ name: call.name,
730
+ tier: call.tier,
731
+ provider: binding.provider,
732
+ endpoint: binding.endpoint,
733
+ model: binding.model,
734
+ consumes: call.consumes,
735
+ produces: call.produces,
736
+ prompt: call.prompt,
737
+ conditional: call.conditional,
738
+ when: call.when
739
+ };
740
+ });
741
+ }
742
+ function distinctProviders(steps) {
743
+ const seen = /* @__PURE__ */ new Set();
744
+ const out = [];
745
+ for (const s of steps) {
746
+ if (!seen.has(s.provider)) {
747
+ seen.add(s.provider);
748
+ out.push(s.provider);
749
+ }
750
+ }
751
+ return out;
752
+ }
753
+ function usesLocalProvider(steps) {
754
+ return steps.some((s) => s.provider !== "anthropic");
755
+ }
756
+ function planProviderRouting(node, models, anthropicSplit) {
757
+ const steps = resolveStagedSteps(node, models);
758
+ const providers = distinctProviders(steps);
759
+ let mode;
760
+ if (usesLocalProvider(steps)) {
761
+ mode = "hybrid-staged";
762
+ } else if (anthropicSplit) {
763
+ mode = "sdk-split";
764
+ } else {
765
+ mode = "single";
766
+ }
767
+ return { mode, steps, providers };
768
+ }
769
+ function buildStepMessages(step, question, slots) {
770
+ const parts = [`Question: ${question}`];
771
+ for (const slot of step.consumes) {
772
+ const value = slots[slot];
773
+ parts.push(
774
+ value === void 0 ? `
775
+ [input '${slot}' was not produced by an earlier step]` : `
776
+ Input '${slot}':
777
+ ${value}`
778
+ );
779
+ }
780
+ return [
781
+ { role: "system", content: step.prompt },
782
+ { role: "user", content: parts.join("\n") }
783
+ ];
784
+ }
785
+
786
+ // src/targets.ts
787
+ var DEFAULT_TARGET = "claude-agent-sdk:local";
788
+ var KNOWN_TARGETS = ["claude-agent-sdk:local"];
789
+ function isKnownTarget(value) {
790
+ return KNOWN_TARGETS.includes(value);
791
+ }
792
+ function knownTargetNames() {
793
+ return KNOWN_TARGETS;
794
+ }
795
+ function entry(outcome, via, provided_by, criticality, note) {
796
+ return { outcome, via, provided_by, criticality, note };
797
+ }
798
+ function localProfile() {
799
+ return {
800
+ "sql_execution:read_only": entry("native", "bash-wren", "runtime", "required", null),
801
+ genbi_build: entry("native", "bash-wren", "runtime", "required", null),
802
+ // Reading the semantic model's structure (models/metrics/lineage) is borrowed from the `wren`
803
+ // CLI (`wren context show`), same mechanism as sql_execution/genbi_build — realize-via bash-wren.
804
+ // Matches the file target's headless/interactive profiles (not a differentiator across back-ends).
805
+ semantic_introspection: entry("realize-via", "bash-wren", "runtime", "required", null),
806
+ // Reading bound-project raw material is natively available through the SDK's cwd-scoped Read
807
+ // tool. This does not grant Bash, network access, or writes; the read_only_execution guardrail
808
+ // still confines every filesystem access to the resolved project root.
809
+ raw_material_read: entry("native", "sdk-read", "runtime", "required", null),
810
+ // +Constitutive: reading the semantic model's structure to propose a context edit (models/
811
+ // metrics/knowledge) — realized the same way as semantic_introspection, via the `wren` CLI.
812
+ // Matches the file target (not a differentiator across back-ends).
813
+ schema_introspection: entry("realize-via", "bash-wren", "runtime", "required", null),
814
+ // genbi-setup: onboarding a NEW wren project (no pre-bound context yet). Realized the same way as
815
+ // the other `wren`-CLI-backed capabilities — bash-setup covers `wren` onboarding/context-build
816
+ // commands plus (under setup_execution's broadened Bash) connector CLIs like `dlt`. `required`,
817
+ // not safety-critical: unlike human_approval, a target without it should just wall-hit the
818
+ // specific setup component, not gate every other capability.
819
+ source_connect: entry("realize-via", "bash-setup", "runtime", "required", null),
820
+ context_build: entry("realize-via", "bash-setup", "runtime", "required", null),
821
+ "llm:strong": entry("native", null, "runtime", "required", null),
822
+ "llm:cheap": entry("native", null, "runtime", "required", null),
823
+ // The differentiator vs the file target: the SDK varies the model per call in-loop, so per-step
824
+ // tier is NATIVE here — no static subagent files, no isolated-invocation marshaling required.
825
+ "llm:per_step_tier": entry("native", "in-loop-model", "runtime", "required", null),
826
+ // Per-step PROVIDER routing (cloud+local mixed in one run) — the hybrid capability, distinct from
827
+ // per_step_tier (same-provider model selection). Warble realizes it two ways (WARBLE_HYBRID_MODE):
828
+ // `staged-executor` (the back-end drives the steps) or `in-process-mcp` (an orchestrator query()
829
+ // calls a dispatch_step tool). provided_by warble because Warble supplies the executor/tool; the
830
+ // model runtimes (Claude SDK loop, ollama) are borrowed.
831
+ "llm:per_step_provider": entry(
832
+ "realize-via",
833
+ "staged-executor|in-process-mcp",
834
+ "warble",
835
+ "required",
836
+ null
837
+ ),
838
+ // Reuse the Warble reference renderer (shell out to `warble render`) — realize-via, same
839
+ // deterministic HTML the file target produces.
840
+ render_contract: entry("realize-via", "warble-render", "runtime", "best-effort", null),
841
+ // Captured directly from the query() message stream (usage/cost per step) — no --output-format
842
+ // plumbing needed; it is inherent to the in-loop runtime.
843
+ structured_output_capture: entry("native", "message-stream", "runtime", "required", null),
844
+ // MVP is read-only, so no component requires approval; keep it a safety-critical loud-fail so a
845
+ // future mutating component targeting this profile fails loudly rather than running unapproved.
846
+ human_approval: entry(
847
+ "fail",
848
+ null,
849
+ "none",
850
+ "safety-critical",
851
+ "no approval channel wired for the programmatic local run in MVP"
852
+ ),
853
+ write_authz: entry("realize-via", "fs", "runtime", "safety-critical", null),
854
+ artifact_write: entry("realize-via", "fs", "runtime", "safety-critical", null),
855
+ // +Assertive borrows the scheduling / event / notify transports from the runtime (OS cron,
856
+ // pub/sub, MCP). The IR names the capability + criticality only; the mechanism is legalized here,
857
+ // never in the IR (capability-model §6/§7). A target with no mechanism wired keeps these `fail`.
858
+ scheduler: entry("realize-via", "os-cron", "runtime", "required", null),
859
+ event_bus: entry("realize-via", "pub-sub", "runtime", "required", null),
860
+ notify_channel: entry("realize-via", "mcp-notify", "runtime", "required", null),
861
+ blast_radius: entry(
862
+ "fail",
863
+ null,
864
+ "warble",
865
+ "safety-critical",
866
+ "requires fine_grained_binding"
867
+ ),
868
+ // +Mutating borrows checkpoint/rollback from version control (git), the same mechanism the
869
+ // workspace conventions already require before a mutating apply. This single SDK target has no
870
+ // human/approval channel wired (see human_approval/blast_radius above), so a mutating component
871
+ // that also requires those still correctly loud-fails here — version_control alone does not
872
+ // authorize the apply.
873
+ version_control: entry("realize-via", "git", "runtime", "required", null)
874
+ };
875
+ }
876
+ function profileFor(targetId) {
877
+ return isKnownTarget(targetId) ? localProfile() : null;
878
+ }
879
+
880
+ // src/options.ts
881
+ var PER_STEP_PROVIDER_CAPABILITY = "llm:per_step_provider";
882
+ var DEFAULT_RENDER_FLAVOR = "programmatic";
883
+ function parseRenderFlavor(value) {
884
+ if (value === "programmatic" || value === "prompt") return value;
885
+ throw new DispatchError(
886
+ `unknown --render-flavor '${value}' (expected: programmatic, prompt)`
887
+ );
888
+ }
889
+ var DATA_ACCESS_CAPABILITIES = [
890
+ "sql_execution:read_only",
891
+ "genbi_build",
892
+ "semantic_introspection",
893
+ "schema_introspection",
894
+ // +Setup (genbi-setup): source_connect/context_build are realized via Bash (the `wren` CLI plus,
895
+ // under the setup_execution guardrail below, connector CLIs like `dlt`), so they grant Bash too.
896
+ "source_connect",
897
+ "context_build"
898
+ ];
899
+ var READ_ONLY_GUARDRAIL_NAME = "read_only_execution";
900
+ var ARTIFACT_WRITE_GUARDRAIL_NAME = "artifact_write";
901
+ var SETUP_GUARDRAIL_NAME = "setup_execution";
902
+ var RENDER_CONTRACT_CAPABILITY = "render_contract";
903
+ var DEFAULT_ARTIFACT_SCOPE = ".";
904
+ var DESTRUCTIVE_BASH_DENY = ["Bash(rm:*)", "Bash(sudo:*)", "Bash(dd:*)"];
905
+ var DEFAULT_MAX_TURNS = 40;
906
+ function unsupported(field, value) {
907
+ return new DispatchError(
908
+ `${field} '${value}' is not supported by the claude-agent-sdk:local target (wall-hit)`
909
+ );
910
+ }
911
+ function realizationSupported(node) {
912
+ return node.realization_kind === "skill" || node.realization_kind === "tool" || node.realization_kind === "gated-tool";
913
+ }
914
+ function triggerSupported(node) {
915
+ return node.trigger.kind === "one_shot" || node.trigger.kind === "scheduled";
916
+ }
917
+ function outcomeSupported(node) {
918
+ return node.effect.outcome.kind === "none" || node.effect.outcome.kind === "assertion" || node.effect.outcome.kind === "mutation";
919
+ }
920
+ function isAssertion(node) {
921
+ return node.effect.outcome.kind === "assertion";
922
+ }
923
+ function isMutation(node) {
924
+ return node.effect.outcome.kind === "mutation";
925
+ }
926
+ function hasDataAccess(caps) {
927
+ return caps.some((c) => DATA_ACCESS_CAPABILITIES.includes(c));
928
+ }
929
+ function isReadOnly(guardrails) {
930
+ return guardrails.some((g) => g.name === READ_ONLY_GUARDRAIL_NAME);
931
+ }
932
+ function isSetup(guardrails) {
933
+ return guardrails.some((g) => g.name === SETUP_GUARDRAIL_NAME);
934
+ }
935
+ function findGuardrail(guardrails, name) {
936
+ return guardrails.find((g) => g.name === name);
937
+ }
938
+ function computeSetupScope(guardrails) {
939
+ const g = findGuardrail(guardrails, SETUP_GUARDRAIL_NAME);
940
+ if (!g) return null;
941
+ return g.scope ?? DEFAULT_ARTIFACT_SCOPE;
942
+ }
943
+ function shouldSplitPerStepTier(node) {
944
+ return distinctTiers(node.llm_calls).length > 1;
945
+ }
946
+ function onFailureFor(criticality) {
947
+ return criticality === "best-effort" ? "degrade" : "fail";
948
+ }
949
+ function resolveRenderGate(node, report, flavor) {
950
+ const artifactWrite = findGuardrail(node.guardrails, ARTIFACT_WRITE_GUARDRAIL_NAME);
951
+ if (!artifactWrite || node.effect.render_blocks.length === 0) {
952
+ return { kind: "none", scope: null, flavor: null };
953
+ }
954
+ const renderEntry = report.find((r) => r.capability === RENDER_CONTRACT_CAPABILITY);
955
+ switch (renderEntry?.outcome) {
956
+ case "realize-via":
957
+ return {
958
+ kind: "realize",
959
+ scope: artifactWrite.scope ?? DEFAULT_ARTIFACT_SCOPE,
960
+ flavor,
961
+ onFailure: onFailureFor(renderEntry.criticality)
962
+ };
963
+ case "degrade":
964
+ return { kind: "degrade", scope: null, flavor: null };
965
+ default:
966
+ return { kind: "none", scope: null, flavor: null };
967
+ }
968
+ }
969
+ function gateGrantsWrite(gate) {
970
+ return gate.kind === "realize" && gate.flavor === "prompt";
971
+ }
972
+ function buildTools(node, gate) {
973
+ const dataAccess = hasDataAccess(node.required_capabilities);
974
+ const readOnly = isReadOnly(node.guardrails);
975
+ const setup = isSetup(node.guardrails);
976
+ const grantsWrite = gateGrantsWrite(gate);
977
+ const mutating = !readOnly;
978
+ const tools = ["Read"];
979
+ if (dataAccess) tools.push("Bash");
980
+ if (mutating) tools.push("Edit");
981
+ if (mutating || grantsWrite) tools.push("Write");
982
+ const allowedTools = ["Read"];
983
+ const disallowedTools = readOnly || setup ? [...DESTRUCTIVE_BASH_DENY] : [];
984
+ return { tools, allowedTools, disallowedTools };
985
+ }
986
+ var ENVELOPE_EXAMPLE = `\`\`\`json
987
+ {
988
+ "blocks": [
989
+ { "type": "kpi_card", "label": "Total revenue", "value": 1672.4, "unit": "USD" },
990
+ { "type": "table", "columns": ["status", "orders"], "rows": [["completed", 67], ["shipped", 32]] },
991
+ { "type": "chart", "chart_type": "bar", "x": "status", "series": ["orders"],
992
+ "rows": [["completed", 67], ["shipped", 32]] },
993
+ { "type": "definition", "sql": "SELECT status, count(*) AS orders FROM orders GROUP BY status",
994
+ "source_tables": ["orders"], "filters": [] }
995
+ ],
996
+ "verified": true,
997
+ "summary": "One or two sentences of prose (optional)."
998
+ }
999
+ \`\`\``;
1000
+ var VERIFY_DEFINITION_CONTRACT = 'Before you answer you MUST verify (per-answer verify, required): actually execute the query through `wren`, then validate the result set is legitimate (non-empty where a value is expected, types/units sane, grain matches the question). If it is not, repair the query and re-run; if it still cannot be validated, REFUSE \u2014 say so plainly and do not fabricate a number. Set the envelope\'s top-level `"verified": true` ONLY when a query ran and its result set passed validation. Always include one `definition` block \u2014 the shallow "how this was computed" card: the exact `sql` you ran, the `source_tables` it read, and the `filters` you applied. This is run-level provenance only; do not invent unit/owner/formal-metric lineage (that is Phase 2).';
1001
+ function formatRenderBlock(block) {
1002
+ const fields = Object.entries(block.fields).map(([k, v]) => `${k}: ${v}`).join(", ");
1003
+ return `- \`${block.type}\`: { ${fields} }`;
1004
+ }
1005
+ function buildProgrammaticRenderSection(node) {
1006
+ return [
1007
+ "## Render output",
1008
+ "",
1009
+ "Block contract (produce data matching these shapes, not prose):",
1010
+ "",
1011
+ ...node.effect.render_blocks.map(formatRenderBlock),
1012
+ "",
1013
+ "Do NOT write any files and do NOT format the answer as prose or markdown. After gathering the data via `wren`, your FINAL message must be a SINGLE JSON object \u2014 the render envelope \u2014 and nothing else: a `blocks` array of instances conforming to the contract above, plus an optional `summary` string. A downstream renderer turns this envelope into the dashboard deterministically; you stay read-only.",
1014
+ "",
1015
+ VERIFY_DEFINITION_CONTRACT,
1016
+ "",
1017
+ "Envelope shape:",
1018
+ "",
1019
+ ENVELOPE_EXAMPLE
1020
+ ].join("\n");
1021
+ }
1022
+ function buildPromptRenderSection(node, gate) {
1023
+ const scope = gate.scope ?? DEFAULT_ARTIFACT_SCOPE;
1024
+ return [
1025
+ "## Render output",
1026
+ "",
1027
+ "Block contract (produce data matching these shapes, not prose):",
1028
+ "",
1029
+ ...node.effect.render_blocks.map(formatRenderBlock),
1030
+ "",
1031
+ `After gathering the data via \`wren\`, write a SINGLE self-contained \`dashboard.html\` file into the artifact-write scope directory (\`${scope}\`), rendering the blocks above: KPI cards, an HTML table, and a simple chart (inline SVG or a CDN-loaded chart library \u2014 no build step). Also render a \`\u2713 Verified\` pill next to the title and a "how this was computed" definition panel (the SQL you ran, source tables, filters). End your reply stating the path of the file you wrote.`,
1032
+ "",
1033
+ VERIFY_DEFINITION_CONTRACT
1034
+ ].join("\n");
1035
+ }
1036
+ function buildRenderSection(node, gate) {
1037
+ switch (gate.kind) {
1038
+ case "realize":
1039
+ return gate.flavor === "prompt" ? buildPromptRenderSection(node, gate) : buildProgrammaticRenderSection(node);
1040
+ case "degrade":
1041
+ return [
1042
+ "## Render output",
1043
+ "",
1044
+ "This target has no artifact-write surface for render output: render the results as a markdown table plus a short prose summary instead. Do not write any files."
1045
+ ].join("\n");
1046
+ case "none":
1047
+ return null;
1048
+ }
1049
+ }
1050
+ var VERDICT_ENVELOPE_EXAMPLE = `\`\`\`json
1051
+ {
1052
+ "blocks": [
1053
+ { "type": "status", "state": "stale", "label": "orders freshness",
1054
+ "detail": "max(order_date) is 51h old; expected within 24h", "severity": "critical" }
1055
+ ],
1056
+ "verdict": { "type": "freshness_verdict", "fresh": false, "observed_lag_hours": 51, "expected_cadence": "24h" },
1057
+ "emitted": ["freshness_breach"],
1058
+ "verified": true
1059
+ }
1060
+ \`\`\``;
1061
+ function buildAssertionSection(node) {
1062
+ const outcome = node.effect.outcome;
1063
+ const verdictType = outcome.verdict_type ?? "verdict";
1064
+ const emits = outcome.emits ?? [];
1065
+ const actions = node.borrowed_actions.length > 0 ? node.borrowed_actions.map((a) => `\`${a}\``).join(", ") : "a runtime notify channel";
1066
+ const emitsLine = emits.length === 0 ? "This assertion emits no signals." : `On breach, list the emitted signal name(s) in the envelope's \`emitted\` array: [${emits.map((e) => `\`${e}\``).join(", ")}]. The runtime routes those signals to the borrowed on-breach actions (${actions}) over the notify channel \u2014 Warble declares the wiring (signal \u2194 action); the transport (Slack / Jira / MCP) is borrowed, not owned by this agent.`;
1067
+ return [
1068
+ "## Assertion output",
1069
+ "",
1070
+ `This is an **assertive** component (outcome: assertion, verdict_type \`${verdictType}\`). Its core is a DETERMINISTIC check, not a judgment call: run the freshness assert through \`wren\` \u2014 \`SELECT max(<timestamp column>)\` on the bound model \u2014 and compare the observed lag against the expected cadence (\`expected_cadence\` param, or the MDL's declared cadence). Fresh iff the newest row is within the cadence; stale otherwise. Do NOT ask an LLM to decide fresh-vs-stale \u2014 that is a SQL comparison and must be reproducible.`,
1071
+ "",
1072
+ "Only when the data is STALE do you use judgment, via the `assess_severity` step, to classify how bad it is (e.g. warn vs critical) from the lag magnitude and history. When fresh, there is no severity to assess.",
1073
+ "",
1074
+ "Verdict block contract (produce data matching these shapes, not prose):",
1075
+ "",
1076
+ ...node.effect.render_blocks.map(formatRenderBlock),
1077
+ "",
1078
+ 'Stay strictly read-only: only `SELECT` through `wren`, never write to the warehouse and never write any files. Your FINAL message MUST be a SINGLE JSON object \u2014 the verdict envelope \u2014 and nothing else: a `blocks` array (the `status` block above), a `verdict` object (`{ type, fresh, ... }`), and, on breach, an `emitted` array. A downstream renderer turns the `status` block into HTML deterministically; you stay read-only. Set the top-level `"verified": true` only when the assert query actually ran and its result was validated.',
1079
+ "",
1080
+ emitsLine,
1081
+ "",
1082
+ "Envelope shape:",
1083
+ "",
1084
+ VERDICT_ENVELOPE_EXAMPLE
1085
+ ].join("\n");
1086
+ }
1087
+ var MUTATION_DIFF_ENVELOPE_EXAMPLE = `\`\`\`json
1088
+ {
1089
+ "blocks": [
1090
+ { "type": "diff", "target": "models/orders.yml", "change_type": "update",
1091
+ "diff": "--- a/models/orders.yml\\n+++ b/models/orders.yml\\n@@ -3,1 +3,1 @@\\n- grain: order_id\\n+ grain: order_id, order_date" }
1092
+ ],
1093
+ "blast_radius": { "downstream_nodes": ["metric:total_revenue"], "protected_hit": false },
1094
+ "applied": false,
1095
+ "verified": true
1096
+ }
1097
+ \`\`\``;
1098
+ var CONTEXT_MUTATION_DIFF_ENVELOPE_EXAMPLE = `\`\`\`json
1099
+ {
1100
+ "blocks": [
1101
+ { "type": "diff", "target": "models/orders.yml", "change_type": "mdl_bootstrap",
1102
+ "diff": "--- a/models/orders.yml\\n+++ b/models/orders.yml\\n@@ -3,1 +3,1 @@\\n- grain: order_id\\n+ grain: order_id, order_date" }
1103
+ ],
1104
+ "applied": false,
1105
+ "verified": true
1106
+ }
1107
+ \`\`\``;
1108
+ function buildContextMutationSection(node) {
1109
+ const outcome = node.effect.outcome;
1110
+ const target = outcome.target ?? "the bound node";
1111
+ const changeType = outcome.change_type ?? "update";
1112
+ const contextGuardrail = findGuardrail(node.guardrails, "context_write_authz");
1113
+ const scope = contextGuardrail?.scope ?? DEFAULT_ARTIFACT_SCOPE;
1114
+ return [
1115
+ "## Mutation output",
1116
+ "",
1117
+ `This is a **constitutive** component (outcome: mutation, target \`${target}\`, change_type \`${changeType}\`). It runs the same two-phase gated lifecycle as any mutating component, never a direct write:`,
1118
+ "",
1119
+ '1. **Dry-run first (must_dry_run).** Propose the edit as a DIFF only \u2014 do not apply it. Your first-phase FINAL message must be a single JSON envelope carrying a `diff` block (the exact unified diff you intend to apply) and `"applied": false`. Never write to the target file in this phase.',
1120
+ "",
1121
+ `2. **Context-write gate (context_write_authz, locked, scope \`${scope}\`).** This is a scoped PATH-AUTHORIZATION check, NOT a downstream-lineage impact computation \u2014 the proposed write must resolve to a path inside the \`${scope}\` scope (the models/metrics/knowledge structure this component owns) or it is denied outright. Writing outside this scope is never permitted, however small the change.`,
1122
+ "",
1123
+ "3. **Human approval (human_approval, locked).** Applying the diff is gated on explicit approval delivered over the runtime's approval channel. On a target with no human/approval channel wired, this component cannot run past the dry-run phase \u2014 that is the honest capability edge, not a bug to route around.",
1124
+ "",
1125
+ '4. **Apply + rollback (rollback_available).** Only apply after approval clears. A git checkpoint is taken first so the apply can be rolled back; rollback is BORROWED from version control, not owned by this agent. After applying, set `"applied": true` in your final envelope.',
1126
+ "",
1127
+ "Diff block contract (produce data matching this shape, not prose):",
1128
+ "",
1129
+ ...node.effect.render_blocks.map(formatRenderBlock),
1130
+ "",
1131
+ 'Your FINAL message at each phase MUST be a SINGLE JSON object \u2014 the mutation envelope \u2014 and nothing else: a `blocks` array (the `diff` block above) and "applied" (`false` on the dry-run, `true` only after a real apply). Set the top-level "verified": true only when the diff was actually computed against the live target (never fabricated).',
1132
+ "",
1133
+ "Envelope shape:",
1134
+ "",
1135
+ CONTEXT_MUTATION_DIFF_ENVELOPE_EXAMPLE
1136
+ ].join("\n");
1137
+ }
1138
+ function buildMutationSection(node) {
1139
+ const outcome = node.effect.outcome;
1140
+ if (outcome.target === "context") {
1141
+ return buildContextMutationSection(node);
1142
+ }
1143
+ const target = outcome.target ?? "the bound node";
1144
+ const changeType = outcome.change_type ?? "update";
1145
+ return [
1146
+ "## Mutation output",
1147
+ "",
1148
+ `This is a **mutating** component (outcome: mutation, target \`${target}\`, change_type \`${changeType}\`). It runs a two-phase gated lifecycle, never a direct write:`,
1149
+ "",
1150
+ '1. **Dry-run first (must_dry_run).** Propose the edit as a DIFF only \u2014 do not apply it. Your first-phase FINAL message must be a single JSON envelope carrying a `diff` block (the exact unified diff you intend to apply) and `"applied": false`. Never write to the target file or the warehouse in this phase.',
1151
+ "",
1152
+ "2. **Blast-radius gate (blast_radius_limit).** The downstream impact of the edited node is computed from Warble's `blast_radius` over the MDL lineage graph, not by you. An empty radius auto-allows; exceeding the guardrail's threshold escalates to human approval; touching a protected asset blocks outright. Report the affected downstream nodes you are aware of in the envelope's `blast_radius` field, but the gate decision itself is made by the runtime, not by your judgment.",
1153
+ "",
1154
+ "3. **Human approval (human_approval, locked).** Applying the diff is gated on explicit approval delivered over the runtime's approval channel. On a target with no human/approval channel wired, this component cannot run past the dry-run phase \u2014 that is the honest capability edge, not a bug to route around.",
1155
+ "",
1156
+ '4. **Apply + rollback (rollback_available).** Only apply after approval clears. A git checkpoint is taken first so the apply can be rolled back; rollback is BORROWED from version control, not owned by this agent. After applying, set `"applied": true` in your final envelope.',
1157
+ "",
1158
+ "Diff block contract (produce data matching this shape, not prose):",
1159
+ "",
1160
+ ...node.effect.render_blocks.map(formatRenderBlock),
1161
+ "",
1162
+ 'Your FINAL message at each phase MUST be a SINGLE JSON object \u2014 the mutation envelope \u2014 and nothing else: a `blocks` array (the `diff` block above), a `blast_radius` object, and `"applied"` (`false` on the dry-run, `true` only after a real apply). Set the top-level `"verified": true` only when the diff was actually computed against the live target (never fabricated).',
1163
+ "",
1164
+ "Envelope shape:",
1165
+ "",
1166
+ MUTATION_DIFF_ENVELOPE_EXAMPLE
1167
+ ].join("\n");
1168
+ }
1169
+ function buildPreamble(cwd) {
1170
+ return [
1171
+ `You are bound to the wren project at \`${cwd}\` (your working directory).`,
1172
+ "All data access MUST go through the `wren` CLI (e.g. `wren --sql ...`, `wren cube list`, `wren genbi build ...`) \u2014 never raw SQL clients, never filesystem tricks against the underlying warehouse."
1173
+ ].join("\n");
1174
+ }
1175
+ function toAgentModel(model) {
1176
+ if (model === "sonnet" || model === "opus" || model === "haiku" || model === "inherit") {
1177
+ return model;
1178
+ }
1179
+ throw new DispatchError(
1180
+ `per-step-tier realization on claude-agent-sdk:local requires each tier's model to be one of sonnet|opus|haiku|inherit (SDK agents[].model is a restricted alias union), but got '${model}'. Use those aliases in --models-config, or the single-tier collapse path.`
1181
+ );
1182
+ }
1183
+ function subagentName(verb, callName) {
1184
+ return `${verb}__${callName}`;
1185
+ }
1186
+ function buildDriverBody(node) {
1187
+ const producers = /* @__PURE__ */ new Map();
1188
+ for (const call of node.llm_calls) {
1189
+ if (call.produces) producers.set(call.produces, call.name);
1190
+ }
1191
+ const steps = node.llm_calls.map((call, i) => {
1192
+ const parts = [
1193
+ `Run the \`${subagentName(node.verb, call.name)}\` subagent (step \`${call.name}\`) via the Task tool.`
1194
+ ];
1195
+ if (call.consumes.length > 0) {
1196
+ const sources = call.consumes.map((slot) => {
1197
+ const producer = producers.get(slot);
1198
+ return producer ? `\`${slot}\` (the \`${producer}\` subagent's output)` : `\`${slot}\``;
1199
+ }).join(", ");
1200
+ parts.push(`Pass it ${sources} as input.`);
1201
+ }
1202
+ if (call.produces) parts.push(`Take its output as \`${call.produces}\` for the steps after it.`);
1203
+ return `${i + 1}. ${parts.join(" ")}`;
1204
+ });
1205
+ return [
1206
+ `You orchestrate the \`${node.verb}\` steps by delegating each one to its dedicated subagent via the Task tool, in order. Do not perform a step's work yourself \u2014 each step's tier-appropriate subagent does it.`,
1207
+ "",
1208
+ "Steps, in order:",
1209
+ "",
1210
+ ...steps,
1211
+ "",
1212
+ "Marshal each subagent's declared output into the next subagent's declared input exactly as named above; do not invent or rename slots."
1213
+ ].join("\n");
1214
+ }
1215
+ function buildAgents(node, gate, models) {
1216
+ const agents = {};
1217
+ const noGate = { kind: "none", scope: null, flavor: null };
1218
+ const subTools = buildTools(node, noGate);
1219
+ for (const call of node.llm_calls) {
1220
+ const ioNote = `
1221
+
1222
+ (consumes [${call.consumes.join(", ")}] / produces ${call.produces ?? "(none)"})`;
1223
+ const prompt = node.brief ? `${node.brief}
1224
+
1225
+ ${call.prompt}` : call.prompt;
1226
+ agents[subagentName(node.verb, call.name)] = {
1227
+ description: `'${call.name}' step of ${node.verb} (tier: ${call.tier}).`,
1228
+ prompt: prompt + ioNote,
1229
+ tools: subTools.tools,
1230
+ model: toAgentModel(models.require(call.tier))
1231
+ };
1232
+ }
1233
+ return agents;
1234
+ }
1235
+ function tierCollapseNote(node, model) {
1236
+ const tiers = distinctTiers(node.llm_calls);
1237
+ if (tiers.length <= 1) return null;
1238
+ const steps = node.llm_calls.map((c) => `${c.name}=${c.tier}`).join(", ");
1239
+ return `per-step tiers [${steps}] collapsed to single model '${model}'`;
1240
+ }
1241
+ function buildDispatchPlan(node, report, cfg) {
1242
+ if (!realizationSupported(node)) {
1243
+ throw unsupported("realization_kind", node.realization_kind);
1244
+ }
1245
+ if (!triggerSupported(node)) {
1246
+ throw unsupported("trigger.kind", node.trigger.kind);
1247
+ }
1248
+ if (!outcomeSupported(node)) {
1249
+ throw unsupported("outcome.kind", node.effect.outcome.kind);
1250
+ }
1251
+ const gate = resolveRenderGate(node, report, cfg.flavor);
1252
+ const readOnly = isReadOnly(node.guardrails);
1253
+ const setupScope = computeSetupScope(node.guardrails);
1254
+ const permissionMode = "default";
1255
+ const maxTurns = cfg.maxTurns ?? DEFAULT_MAX_TURNS;
1256
+ const renderSection = buildRenderSection(node, gate);
1257
+ const assertionSection = isAssertion(node) ? buildAssertionSection(node) : null;
1258
+ const mutationSection = isMutation(node) ? buildMutationSection(node) : null;
1259
+ const split = shouldSplitPerStepTier(node);
1260
+ if (node.realization_kind === "gated-tool" && split) {
1261
+ throw new DispatchError(
1262
+ `llm:per_step_tier: gated-tool component '${node.verb}' has divergent step tiers, but per-step splitting would grant every subagent the mutation guardrail's write/edit authority alongside the approval-gated driver, duplicating write access outside the two-phase approval lifecycle (dry-run diff -> blast-radius -> human approval -> apply) \u2014 refusing to dispatch rather than silently collapsing the tiers or unsafely splitting write authority. Realize this component as \`tool\` (no approval gate) or \`skill\` to enable per-step-tier splitting, or author it with a single tier to keep it a \`gated-tool\`.`
1263
+ );
1264
+ }
1265
+ const routing = planProviderRouting(node, cfg.models, split);
1266
+ const base = {
1267
+ cwd: cfg.cwd,
1268
+ permissionMode,
1269
+ maxTurns
1270
+ // SDK isolation: do NOT load ambient ~/.claude or project .claude settings, so nothing outside
1271
+ // this plan can widen the tool allowlist. wren strict_mode is read by the wren CLI itself.
1272
+ // (settingSources omitted == isolation mode.)
1273
+ };
1274
+ if (routing.mode === "hybrid-staged") {
1275
+ return buildHybridStagedPlan(node, gate, cfg, base, readOnly, routing.providers, routing.steps);
1276
+ }
1277
+ if (split) {
1278
+ const agents = buildAgents(node, gate, cfg.models);
1279
+ const driverTools = hasDataAccess(node.required_capabilities) ? ["Task", "Read", "Bash"] : ["Task", "Read"];
1280
+ if (gateGrantsWrite(gate)) driverTools.push("Write");
1281
+ const driverPrompt = [
1282
+ buildPreamble(cfg.cwd),
1283
+ "",
1284
+ ...node.brief ? [node.brief, ""] : [],
1285
+ buildDriverBody(node),
1286
+ ...renderSection ? [
1287
+ "",
1288
+ "You collect the subagents' output and produce the render output yourself (the subagents never do).",
1289
+ "",
1290
+ renderSection
1291
+ ] : [
1292
+ "",
1293
+ // No render section (e.g. answer_query): the final step already produced the user-facing
1294
+ // structured answer — including its `verified` facet and shallow `definition` (G2/G3).
1295
+ // Pass it through verbatim; do NOT re-prose or drop those fields, or the ✓ Verified cue
1296
+ // and definition card are lost on the way out.
1297
+ "Your FINAL message MUST be the terminal step's structured output verbatim \u2014 a single JSON object with its `columns`/`rows` (or refusal) plus the `verified` boolean and the shallow `definition` it emitted. Do not summarize it into prose or drop any field."
1298
+ ],
1299
+ ...assertionSection ? ["", assertionSection] : [],
1300
+ ...mutationSection ? ["", mutationSection] : []
1301
+ ].join("\n");
1302
+ const subagentModels = {};
1303
+ for (const call of node.llm_calls) {
1304
+ subagentModels[subagentName(node.verb, call.name)] = cfg.models.require(call.tier);
1305
+ }
1306
+ const options2 = {
1307
+ ...base,
1308
+ model: cfg.models.orchestrator(),
1309
+ systemPrompt: driverPrompt,
1310
+ agents,
1311
+ tools: driverTools,
1312
+ allowedTools: ["Read", "Task"],
1313
+ disallowedTools: readOnly ? [...DESTRUCTIVE_BASH_DENY] : []
1314
+ };
1315
+ return {
1316
+ prompt: cfg.question,
1317
+ options: options2,
1318
+ meta: {
1319
+ verb: node.verb,
1320
+ target: cfg.target,
1321
+ readOnly,
1322
+ split: true,
1323
+ render: gate,
1324
+ assertion: isAssertion(node),
1325
+ mutation: isMutation(node),
1326
+ model: cfg.models.orchestrator(),
1327
+ subagentModels,
1328
+ tierCollapseNote: null,
1329
+ mode: "sdk-split",
1330
+ providers: ["anthropic"],
1331
+ stagedSteps: [],
1332
+ setupScope
1333
+ }
1334
+ };
1335
+ }
1336
+ const model = cfg.models.collapsedModel(node.llm_calls);
1337
+ const toolPlan = buildTools(node, gate);
1338
+ const systemPrompt = [
1339
+ buildPreamble(cfg.cwd),
1340
+ "",
1341
+ ...node.brief ? [node.brief, ""] : [],
1342
+ node.prompt_fragment,
1343
+ ...renderSection ? ["", renderSection] : [],
1344
+ ...assertionSection ? ["", assertionSection] : [],
1345
+ ...mutationSection ? ["", mutationSection] : []
1346
+ ].join("\n");
1347
+ const options = {
1348
+ ...base,
1349
+ model,
1350
+ systemPrompt,
1351
+ tools: toolPlan.tools,
1352
+ allowedTools: toolPlan.allowedTools,
1353
+ disallowedTools: toolPlan.disallowedTools
1354
+ };
1355
+ return {
1356
+ prompt: cfg.question,
1357
+ options,
1358
+ meta: {
1359
+ verb: node.verb,
1360
+ target: cfg.target,
1361
+ readOnly,
1362
+ split: false,
1363
+ render: gate,
1364
+ assertion: isAssertion(node),
1365
+ mutation: isMutation(node),
1366
+ model,
1367
+ subagentModels: {},
1368
+ tierCollapseNote: tierCollapseNote(node, model),
1369
+ mode: "single",
1370
+ providers: ["anthropic"],
1371
+ stagedSteps: [],
1372
+ setupScope
1373
+ }
1374
+ };
1375
+ }
1376
+ function buildHybridStagedPlan(node, gate, cfg, base, readOnly, providers, steps) {
1377
+ const perStepProvider = profileFor(cfg.target)?.[PER_STEP_PROVIDER_CAPABILITY];
1378
+ if (!perStepProvider || perStepProvider.outcome === "fail") {
1379
+ throw new DispatchError(
1380
+ `${PER_STEP_PROVIDER_CAPABILITY}: fail on ${cfg.target} \u2014 the binding routes a step to a non-Anthropic provider (${providers.filter((p) => p !== "anthropic").join(", ")}), but this target does not support per-step provider routing (hybrid). Use an all-cloud binding, or a target that realizes ${PER_STEP_PROVIDER_CAPABILITY}.`
1381
+ );
1382
+ }
1383
+ if (gate.kind !== "none") {
1384
+ throw new DispatchError(
1385
+ `hybrid-staged provider routing does not yet realize a '${gate.kind}' render gate on ${cfg.target} (wall-hit); the POC covers render-none components like answer_query. Bind this component all-cloud, or extend the staged executor's render handling.`
1386
+ );
1387
+ }
1388
+ const toolPlan = buildTools(node, gate);
1389
+ let driverModel;
1390
+ try {
1391
+ driverModel = cfg.models.orchestrator();
1392
+ } catch {
1393
+ driverModel = cfg.models.collapsedModel(node.llm_calls);
1394
+ }
1395
+ const options = {
1396
+ ...base,
1397
+ model: driverModel,
1398
+ tools: toolPlan.tools,
1399
+ allowedTools: toolPlan.allowedTools,
1400
+ disallowedTools: toolPlan.disallowedTools
1401
+ };
1402
+ return {
1403
+ // The staged executor assembles each step's prompt from `meta.stagedSteps`; the top-level prompt is
1404
+ // the raw question (marshaled per step by run.ts).
1405
+ prompt: cfg.question,
1406
+ options,
1407
+ meta: {
1408
+ verb: node.verb,
1409
+ target: cfg.target,
1410
+ readOnly,
1411
+ assertion: isAssertion(node),
1412
+ mutation: isMutation(node),
1413
+ split: false,
1414
+ render: gate,
1415
+ model: `hybrid-staged(${providers.join("+")})`,
1416
+ subagentModels: {},
1417
+ tierCollapseNote: null,
1418
+ mode: "hybrid-staged",
1419
+ providers,
1420
+ stagedSteps: steps,
1421
+ // Hybrid+setup is out of scope (locked decision): a setup component's steps are not staged
1422
+ // across providers, so this path never sees setup_execution in practice; null is the safe,
1423
+ // explicit default rather than silently inheriting a scope this path doesn't enforce.
1424
+ setupScope: null
1425
+ }
1426
+ };
1427
+ }
1428
+
1429
+ // src/resolve.ts
1430
+ function unknownCapabilityEntry() {
1431
+ return {
1432
+ outcome: "fail",
1433
+ via: null,
1434
+ provided_by: "none",
1435
+ criticality: "safety-critical",
1436
+ note: "capability is not declared in the target's capability profile \u2014 unknown means it cannot be guaranteed"
1437
+ };
1438
+ }
1439
+ function impliedCapabilities(node) {
1440
+ const implied = [];
1441
+ if (distinctTiers(node.llm_calls).length > 1) {
1442
+ implied.push("llm:per_step_tier");
1443
+ }
1444
+ switch (node.trigger.kind) {
1445
+ case "scheduled":
1446
+ implied.push("scheduler");
1447
+ break;
1448
+ case "event":
1449
+ implied.push("event_bus");
1450
+ break;
1451
+ case "one_shot":
1452
+ break;
1453
+ }
1454
+ if ((node.effect.outcome.emits?.length ?? 0) > 0) {
1455
+ implied.push("event_bus");
1456
+ }
1457
+ if (node.effect.render_blocks.length > 0) {
1458
+ implied.push("render_contract");
1459
+ }
1460
+ if (node.effect.outcome.kind === "mutation") {
1461
+ implied.push("version_control");
1462
+ if (node.effect.outcome.target === "context") {
1463
+ implied.push("context_write_authz");
1464
+ } else {
1465
+ implied.push("write_authz");
1466
+ }
1467
+ }
1468
+ return implied;
1469
+ }
1470
+ function collectRequiredCapabilities(node) {
1471
+ const seen = /* @__PURE__ */ new Set();
1472
+ const out = [];
1473
+ for (const cap of [...node.required_capabilities, ...impliedCapabilities(node)]) {
1474
+ if (!seen.has(cap)) {
1475
+ seen.add(cap);
1476
+ out.push(cap);
1477
+ }
1478
+ }
1479
+ return out;
1480
+ }
1481
+ function inspectCapabilities(node, targetId, profile) {
1482
+ const fallback = unknownCapabilityEntry();
1483
+ const report = collectRequiredCapabilities(node).map((capability) => {
1484
+ const e = profile[capability] ?? fallback;
1485
+ const resolved = {
1486
+ capability,
1487
+ outcome: e.outcome,
1488
+ provided_by: e.provided_by,
1489
+ criticality: e.criticality
1490
+ };
1491
+ if (e.note !== null) resolved.note = e.note;
1492
+ return resolved;
1493
+ });
1494
+ return report;
1495
+ }
1496
+ function assertNoFailedCapabilities(report, targetId, verb) {
1497
+ const failed = report.find((r) => r.outcome === "fail");
1498
+ if (failed) {
1499
+ const reason = failed.note ?? "unsupported on this target";
1500
+ throw new DispatchError(
1501
+ `${failed.capability}: fail on ${targetId} (${reason}) \u2014 component '${verb}' cannot be dispatched`
1502
+ );
1503
+ }
1504
+ }
1505
+ function resolveCapabilities(node, targetId, profile) {
1506
+ const report = inspectCapabilities(node, targetId, profile);
1507
+ assertNoFailedCapabilities(report, targetId, node.verb);
1508
+ return report;
1509
+ }
1510
+ function resolveNodeCapabilities(node, targetId) {
1511
+ if (!isKnownTarget(targetId)) {
1512
+ throw new DispatchError(
1513
+ `target '${targetId}' has no capability profile (known targets: ${knownTargetNames().join(", ")})`
1514
+ );
1515
+ }
1516
+ return resolveCapabilities(node, targetId, localProfile());
1517
+ }
1518
+ function inspectNodeCapabilities(node, targetId) {
1519
+ if (!isKnownTarget(targetId)) {
1520
+ throw new DispatchError(
1521
+ `target '${targetId}' has no capability profile (known targets: ${knownTargetNames().join(", ")})`
1522
+ );
1523
+ }
1524
+ return inspectCapabilities(node, targetId, localProfile());
1525
+ }
1526
+
1527
+ // src/guardrails.ts
1528
+ import { resolve as resolvePath, sep as pathSep } from "path";
1529
+ function withinScope(abs, scopeAbs) {
1530
+ return abs === scopeAbs || abs.startsWith(scopeAbs.endsWith(pathSep) ? scopeAbs : scopeAbs + pathSep);
1531
+ }
1532
+ var DESTRUCTIVE = /\b(rm|sudo|dd|mkfs|shutdown|reboot|kill|chmod|chown|mv|cp)\b/;
1533
+ var REDIRECTION = /(^|[^>])>>?[^>]/;
1534
+ var DOTENV_READER_COMMANDS = /\b(cat|head|tail|less|more|od|xxd|strings|grep|awk|sed)\b/;
1535
+ var DOTENV_PATH = /(^|[\s"'/=])\.env(\.[\w.-]+)?(?=$|[\s"'/])/;
1536
+ function referencesDotenvPath(text) {
1537
+ return DOTENV_PATH.test(text);
1538
+ }
1539
+ function firstToken(command) {
1540
+ return command.trim().split(/\s+/)[0] ?? "";
1541
+ }
1542
+ function allow(input) {
1543
+ return { behavior: "allow", updatedInput: input };
1544
+ }
1545
+ function deny(message) {
1546
+ return { behavior: "deny", message };
1547
+ }
1548
+ function makeSetupReadDenyHook(cfg, denials) {
1549
+ if (cfg.setupScope == null) return [];
1550
+ return [
1551
+ {
1552
+ matcher: "Read",
1553
+ hooks: [
1554
+ async (input) => {
1555
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
1556
+ const toolInput = input.tool_input;
1557
+ const target = toolInput != null && typeof toolInput["file_path"] === "string" ? toolInput["file_path"] : "";
1558
+ if (!referencesDotenvPath(target)) return { continue: true };
1559
+ const reason = "reading a dotenv path via Read is blocked by the read_only_execution guardrail; the setup credential design writes an empty .env template and is never meant to read it back.";
1560
+ denials.push({ tool: "Read", reason, command: target });
1561
+ return {
1562
+ continue: false,
1563
+ decision: "block",
1564
+ reason,
1565
+ hookSpecificOutput: {
1566
+ hookEventName: "PreToolUse",
1567
+ permissionDecision: "deny",
1568
+ permissionDecisionReason: reason
1569
+ }
1570
+ };
1571
+ }
1572
+ ]
1573
+ }
1574
+ ];
1575
+ }
1576
+ function makeReadOnlyGuard(cfg) {
1577
+ const denials = [];
1578
+ const hooks = makeSetupReadDenyHook(cfg, denials);
1579
+ const canUseTool = async (toolName, input) => {
1580
+ if (toolName === "Read") {
1581
+ if (cfg.setupScope != null) {
1582
+ const target = typeof input["file_path"] === "string" ? input["file_path"] : "";
1583
+ if (referencesDotenvPath(target)) {
1584
+ const reason2 = "reading a dotenv path via Read is blocked by the read_only_execution guardrail; the setup credential design writes an empty .env template and is never meant to read it back.";
1585
+ denials.push({ tool: "Read", reason: reason2, command: target });
1586
+ return deny(reason2);
1587
+ }
1588
+ }
1589
+ return allow(input);
1590
+ }
1591
+ if (toolName === "Task" || toolName === "TodoWrite") {
1592
+ return allow(input);
1593
+ }
1594
+ if (toolName === "Bash") {
1595
+ const command = typeof input["command"] === "string" ? input["command"] : "";
1596
+ if (DOTENV_READER_COMMANDS.test(command) && referencesDotenvPath(command)) {
1597
+ const reason2 = "reading a dotenv file's contents is blocked by the read_only_execution guardrail; the setup credential design writes an empty .env template and is never meant to read it back.";
1598
+ denials.push({ tool: "Bash", reason: reason2, command });
1599
+ return deny(reason2);
1600
+ }
1601
+ if (DESTRUCTIVE.test(command) || REDIRECTION.test(command)) {
1602
+ const reason2 = "destructive or file-writing bash is blocked by the read_only_execution guardrail; all data access must go through the read-only `wren` CLI.";
1603
+ denials.push({ tool: "Bash", reason: reason2, command });
1604
+ return deny(reason2);
1605
+ }
1606
+ if (cfg.setupScope != null) return allow(input);
1607
+ if (firstToken(command) !== "wren") {
1608
+ const reason2 = "only `wren` CLI invocations are permitted (data access goes through the semantic layer); this command is blocked by the read_only_execution guardrail.";
1609
+ denials.push({ tool: "Bash", reason: reason2, command });
1610
+ return deny(reason2);
1611
+ }
1612
+ return allow(input);
1613
+ }
1614
+ if (toolName === "Write" || toolName === "Edit") {
1615
+ if (cfg.mutation) {
1616
+ if (cfg.mutation.contextScope) {
1617
+ const target = typeof input["file_path"] === "string" ? input["file_path"] : "";
1618
+ const abs = resolvePath(cfg.cwd, target);
1619
+ const scopeAbs = resolvePath(cfg.cwd, cfg.mutation.contextScope);
1620
+ if (!withinScope(abs, scopeAbs)) {
1621
+ const reason5 = `write to '${target}' is outside the context_write_authz scope '${cfg.mutation.contextScope}'.`;
1622
+ denials.push({ tool: toolName, reason: reason5, command: target });
1623
+ return deny(reason5);
1624
+ }
1625
+ const gate2 = cfg.mutation.approvalRequired ? "human approval" : "the must_dry_run gate";
1626
+ const reason4 = `${toolName} is inside the context_write_authz scope '${cfg.mutation.contextScope}', but the apply still requires ${gate2} to clear first; that approval is borrowed from the SDK embedder's own canUseTool/approval channel, which this guard does not provide, so it denies by default (fail-closed).`;
1627
+ denials.push({ tool: toolName, reason: reason4, command: target });
1628
+ return deny(reason4);
1629
+ }
1630
+ const gate = cfg.mutation.approvalRequired ? "human approval" : "the must_dry_run gate";
1631
+ const reason3 = `${toolName} is the gated apply of a mutating component and requires ${gate} to clear first; that approval is borrowed from the SDK embedder's own canUseTool/approval channel, which this guard does not provide, so it denies by default (fail-closed).`;
1632
+ denials.push({ tool: toolName, reason: reason3 });
1633
+ return deny(reason3);
1634
+ }
1635
+ if (cfg.setupScope != null) {
1636
+ const target = typeof input["file_path"] === "string" ? input["file_path"] : "";
1637
+ const abs = resolvePath(cfg.cwd, target);
1638
+ const scopeAbs = resolvePath(cfg.cwd, cfg.setupScope);
1639
+ if (withinScope(abs, scopeAbs)) return allow(input);
1640
+ const reason3 = `write to '${target}' is outside the setup project-root scope '${cfg.setupScope}'.`;
1641
+ denials.push({ tool: toolName, reason: reason3, command: target });
1642
+ return deny(reason3);
1643
+ }
1644
+ if (cfg.writeScope) {
1645
+ const target = typeof input["file_path"] === "string" ? input["file_path"] : "";
1646
+ const abs = resolvePath(cfg.cwd, target);
1647
+ const scopeAbs = resolvePath(cfg.cwd, cfg.writeScope);
1648
+ if (withinScope(abs, scopeAbs)) return allow(input);
1649
+ const reason3 = `write to '${target}' is outside the permitted artifact scope '${cfg.writeScope}'.`;
1650
+ denials.push({ tool: toolName, reason: reason3, command: target });
1651
+ return deny(reason3);
1652
+ }
1653
+ const reason2 = `${toolName} is blocked: this component is read-only (programmatic render flavor keeps the agent from writing files; the dispatcher renders the dashboard from your envelope).`;
1654
+ denials.push({ tool: toolName, reason: reason2 });
1655
+ return deny(reason2);
1656
+ }
1657
+ const reason = `tool '${toolName}' is not permitted for this component.`;
1658
+ denials.push({ tool: toolName, reason });
1659
+ return deny(reason);
1660
+ };
1661
+ return { canUseTool, denials, hooks };
1662
+ }
1663
+
1664
+ // src/localClient.ts
1665
+ function buildChatRequest(model, messages) {
1666
+ return { model, messages, stream: false, temperature: 0 };
1667
+ }
1668
+ function extractCompletionText(body) {
1669
+ const choices = body?.choices;
1670
+ if (!Array.isArray(choices) || choices.length === 0) {
1671
+ throw new Error("openai_compat response has no choices");
1672
+ }
1673
+ const content = choices[0]?.message?.content;
1674
+ if (typeof content !== "string") {
1675
+ throw new Error("openai_compat response choice has no message.content string");
1676
+ }
1677
+ return content;
1678
+ }
1679
+ async function callOpenAiCompat(opts) {
1680
+ const url = `${opts.endpoint.replace(/\/$/, "")}/chat/completions`;
1681
+ const headers = { "content-type": "application/json" };
1682
+ if (opts.apiKey) headers["authorization"] = `Bearer ${opts.apiKey}`;
1683
+ const doFetch = opts.fetchImpl ?? fetch;
1684
+ const res = await doFetch(url, {
1685
+ method: "POST",
1686
+ headers,
1687
+ body: JSON.stringify(buildChatRequest(opts.model, opts.messages))
1688
+ });
1689
+ if (!res.ok) {
1690
+ throw new Error(`openai_compat call to ${url} failed: ${res.status} ${res.statusText}`);
1691
+ }
1692
+ return extractCompletionText(await res.json());
1693
+ }
1694
+
1695
+ // src/hybridTool.ts
1696
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
1697
+ import { join } from "path";
1698
+ import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
1699
+ import { z } from "zod";
1700
+ function isResult(msg) {
1701
+ return msg.type === "result";
1702
+ }
1703
+ function isAssistant(msg) {
1704
+ return msg.type === "assistant";
1705
+ }
1706
+ function requireFinalText(result) {
1707
+ if (result === void 0) throw new DispatchError("the query() stream ended without a result message");
1708
+ if (result.subtype !== "success") {
1709
+ throw new DispatchError(`agent run failed (${result.subtype}): ${result.errors.join("; ")}`);
1710
+ }
1711
+ return result.result;
1712
+ }
1713
+ function cloudPreamble(cwd) {
1714
+ return [
1715
+ `You are bound to the wren project at \`${cwd}\` (your working directory).`,
1716
+ "All data access MUST go through the `wren` CLI \u2014 never raw SQL clients."
1717
+ ].join("\n");
1718
+ }
1719
+ function stepUserPrompt(question, inputsText) {
1720
+ return inputsText ? `Question: ${question}
1721
+
1722
+ Inputs from the previous step:
1723
+ ${inputsText}` : `Question: ${question}`;
1724
+ }
1725
+ function buildToolDriverPrompt(steps) {
1726
+ const producers = /* @__PURE__ */ new Map();
1727
+ for (const s of steps) if (s.produces) producers.set(s.produces, s.name);
1728
+ const lines = steps.map((s, i) => {
1729
+ const parts = [`${i + 1}. Call the \`dispatch_step\` tool with step="${s.name}".`];
1730
+ if (s.consumes.length > 0) {
1731
+ const srcs = s.consumes.map((slot) => {
1732
+ const p = producers.get(slot);
1733
+ return p ? `the text step "${p}" returned` : `"${slot}"`;
1734
+ }).join(", ");
1735
+ parts.push(`Pass ${srcs} as the tool's \`inputs\` argument.`);
1736
+ }
1737
+ if (s.conditional) parts.push("(Only if the previous step's output indicates the query failed and needs repair.)");
1738
+ return parts.join(" ");
1739
+ });
1740
+ return [
1741
+ "You orchestrate a multi-step data task by calling the `dispatch_step` tool exactly once per step, in order.",
1742
+ "You have NO other tools and you must NOT try to answer yourself \u2014 each step runs on its own configured model behind the tool.",
1743
+ "",
1744
+ "Steps, in order:",
1745
+ "",
1746
+ ...lines,
1747
+ "",
1748
+ "Marshal each step's returned text into the next step's `inputs` exactly as noted above. Your FINAL message MUST be the last executed step's returned text verbatim \u2014 do not summarize it or add commentary."
1749
+ ].join("\n");
1750
+ }
1751
+ async function runCloudStep(step, question, inputsText, ctx) {
1752
+ const options = {
1753
+ cwd: ctx.cwd,
1754
+ permissionMode: "default",
1755
+ maxTurns: ctx.maxTurns,
1756
+ model: step.model,
1757
+ systemPrompt: `${cloudPreamble(ctx.cwd)}
1758
+
1759
+ ${step.prompt}`,
1760
+ tools: ["Read", "Bash"],
1761
+ allowedTools: ["Read"],
1762
+ disallowedTools: [...DESTRUCTIVE_BASH_DENY],
1763
+ canUseTool: ctx.canUseTool,
1764
+ // Read never reaches `canUseTool` for an in-cwd path in the real SDK (see guardrails.ts); this
1765
+ // hook is the live enforcement point for the +Setup dotenv-read gap's Read side.
1766
+ hooks: { PreToolUse: ctx.hooks },
1767
+ env: ctx.env
1768
+ };
1769
+ const msgs = [];
1770
+ for await (const m of query({ prompt: stepUserPrompt(question, inputsText), options })) msgs.push(m);
1771
+ return requireFinalText(msgs.find(isResult));
1772
+ }
1773
+ async function runHybridTool(plan, cfg) {
1774
+ mkdirSync(cfg.outDir, { recursive: true });
1775
+ const cwd = plan.options.cwd ?? process.cwd();
1776
+ const { canUseTool, denials, hooks } = makeReadOnlyGuard({
1777
+ readOnly: plan.meta.readOnly,
1778
+ writeScope: null,
1779
+ cwd,
1780
+ setupScope: plan.meta.setupScope
1781
+ });
1782
+ const venvBin = join(cwd, ".venv", "bin");
1783
+ const pathEnv = existsSync(venvBin) ? `${venvBin}:${process.env.PATH ?? ""}` : process.env.PATH ?? "";
1784
+ const env = { ...process.env, PATH: pathEnv };
1785
+ const steps = plan.meta.stagedSteps;
1786
+ const question = plan.prompt;
1787
+ const maxTurns = plan.options.maxTurns ?? 40;
1788
+ const traceSteps = [];
1789
+ const dispatchStep = tool(
1790
+ "dispatch_step",
1791
+ "Execute one named step of the task on its own configured model and return its text output.",
1792
+ { step: z.string(), inputs: z.string().optional() },
1793
+ async (args) => {
1794
+ const step = steps.find((s) => s.name === args.step);
1795
+ if (!step) {
1796
+ return { content: [{ type: "text", text: `ERROR: unknown step '${args.step}'` }], isError: true };
1797
+ }
1798
+ const inputsText = args.inputs ?? "";
1799
+ let text;
1800
+ if (step.provider === "openai_compat") {
1801
+ if (!step.endpoint) throw new DispatchError(`local step '${step.name}' has no endpoint`);
1802
+ text = await callOpenAiCompat({
1803
+ endpoint: step.endpoint,
1804
+ model: step.model,
1805
+ messages: [
1806
+ { role: "system", content: step.prompt },
1807
+ { role: "user", content: stepUserPrompt(question, inputsText) }
1808
+ ]
1809
+ });
1810
+ process.stderr.write(`warble hybrid-tool: step '${step.name}' \u2192 local ${step.model}
1811
+ `);
1812
+ } else {
1813
+ text = await runCloudStep(step, question, inputsText, { cwd, env, maxTurns, canUseTool, hooks });
1814
+ process.stderr.write(`warble hybrid-tool: step '${step.name}' \u2192 cloud ${step.model}
1815
+ `);
1816
+ }
1817
+ traceSteps.push({ model: `${step.provider}:${step.model}`, parent_tool_use_id: step.name, usage: null });
1818
+ return { content: [{ type: "text", text }] };
1819
+ }
1820
+ );
1821
+ const server = createSdkMcpServer({ name: "warble", version: "0.0.0", tools: [dispatchStep] });
1822
+ const driverModel = plan.options.model ?? "sonnet";
1823
+ const driverOptions = {
1824
+ cwd,
1825
+ permissionMode: "default",
1826
+ maxTurns,
1827
+ model: driverModel,
1828
+ systemPrompt: buildToolDriverPrompt(steps),
1829
+ mcpServers: { warble: server },
1830
+ allowedTools: ["mcp__warble__dispatch_step"],
1831
+ env
1832
+ };
1833
+ const msgs = [];
1834
+ for await (const m of query({ prompt: question, options: driverOptions })) msgs.push(m);
1835
+ const result = msgs.find(isResult);
1836
+ const finalText = requireFinalText(result);
1837
+ for (const m of msgs.filter(isAssistant)) {
1838
+ traceSteps.push({ model: m.message.model, parent_tool_use_id: "orchestrator", usage: m.message.usage });
1839
+ }
1840
+ const trace = {
1841
+ target: plan.meta.target,
1842
+ verb: plan.meta.verb,
1843
+ model: `hybrid-tool(driver=${driverModel})`,
1844
+ split: false,
1845
+ run: result && result.subtype === "success" ? { total_cost_usd: result.total_cost_usd, duration_ms: result.duration_ms, duration_api_ms: result.duration_api_ms, num_turns: result.num_turns } : null,
1846
+ usage: null,
1847
+ modelUsage: {},
1848
+ steps: traceSteps,
1849
+ denials
1850
+ };
1851
+ writeFileSync(join(cfg.outDir, "result.txt"), finalText, "utf8");
1852
+ writeFileSync(join(cfg.outDir, "trace.json"), JSON.stringify(trace, null, 2) + "\n", "utf8");
1853
+ return {
1854
+ finalText,
1855
+ trace,
1856
+ htmlPath: null,
1857
+ denials,
1858
+ sessionId: result?.session_id ?? null,
1859
+ renderDegraded: null
1860
+ };
1861
+ }
1862
+
1863
+ // src/render.ts
1864
+ import { spawnSync } from "child_process";
1865
+ import { mkdtempSync, writeFileSync as writeFileSync2 } from "fs";
1866
+ import { tmpdir } from "os";
1867
+ import { join as join2 } from "path";
1868
+ function renderEnvelope(finalText, outPath, opts) {
1869
+ const dir = mkdtempSync(join2(tmpdir(), "warble-sdk-"));
1870
+ const envelopePath = join2(dir, "envelope.txt");
1871
+ writeFileSync2(envelopePath, finalText, "utf8");
1872
+ const args = ["render", envelopePath, "--out", outPath];
1873
+ if (opts.title) args.push("--title", opts.title);
1874
+ const proc = spawnSync(opts.warbleBin, args, { encoding: "utf8" });
1875
+ if (proc.error) {
1876
+ throw new DispatchError(missingWarbleBinaryMessage(opts.warbleBin, proc.error));
1877
+ }
1878
+ if (proc.status !== 0) {
1879
+ throw new DispatchError(
1880
+ `warble render exited ${proc.status}: ${proc.stderr?.trim() || proc.stdout?.trim() || "no output"}`
1881
+ );
1882
+ }
1883
+ return { outPath, log: (proc.stderr ?? "").trim() };
1884
+ }
1885
+ function missingWarbleBinaryMessage(warbleBin, error) {
1886
+ const base = `failed to run '${warbleBin} render': ${error.message}`;
1887
+ if (error.code === "ENOENT") {
1888
+ return `${base}
1889
+ The 'warble' binary was not found. Install it with 'cargo install warble-cli' (requires a Rust toolchain; installs from crates.io), or set the 'warbleBin' option (CLI: --warble-bin <path>) to point at an existing 'warble' binary.`;
1890
+ }
1891
+ return `${base} (set the 'warbleBin' option, or pass --warble-bin <path> on the CLI, to point at a different binary)`;
1892
+ }
1893
+
1894
+ // src/run.ts
1895
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
1896
+ import { join as join3 } from "path";
1897
+ import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
1898
+
1899
+ // src/conditional.ts
1900
+ var DEFAULT_MAX_REPAIR_ATTEMPTS = 1;
1901
+ function tryParseJson(text) {
1902
+ try {
1903
+ return JSON.parse(text);
1904
+ } catch {
1905
+ return void 0;
1906
+ }
1907
+ }
1908
+ function readFlag(slots, target) {
1909
+ const [slotName, ...path] = target.split(".");
1910
+ if (slotName === void 0) return false;
1911
+ const raw = slots[slotName];
1912
+ if (raw === void 0) return false;
1913
+ let cur = tryParseJson(raw);
1914
+ for (const key of path) {
1915
+ if (typeof cur !== "object" || cur === null) return false;
1916
+ cur = cur[key];
1917
+ }
1918
+ return cur === true;
1919
+ }
1920
+ function evaluateGuard(when, state) {
1921
+ switch (when.guard) {
1922
+ case "on_failure":
1923
+ return state.outcomes[when.target] === "failure";
1924
+ case "on_flag":
1925
+ return readFlag(state.slots, when.target);
1926
+ case "on_missing":
1927
+ return state.slots[when.target] === void 0;
1928
+ default:
1929
+ throw new DispatchError(
1930
+ `unknown guard '${when.guard}' (closed vocabulary: on_failure, on_flag, on_missing)`
1931
+ );
1932
+ }
1933
+ }
1934
+ function repairFoldTarget(when, consumes, precedingStep) {
1935
+ if (when.guard !== "on_failure" || precedingStep === null) return null;
1936
+ if (when.target !== precedingStep.name) return null;
1937
+ if (precedingStep.produces === null || !consumes.includes(precedingStep.produces)) return null;
1938
+ return precedingStep;
1939
+ }
1940
+ function classifyConditionalStep(when, consumes, precedingStep, state) {
1941
+ const target = repairFoldTarget(when, consumes, precedingStep);
1942
+ if (target !== null) {
1943
+ return state.outcomes[target.name] === "failure" ? { kind: "repair", target } : { kind: "skip" };
1944
+ }
1945
+ return evaluateGuard(when, state) ? { kind: "run" } : { kind: "skip" };
1946
+ }
1947
+ async function runRepairLoop(maxAttempts, attempt) {
1948
+ for (let i = 1; i <= maxAttempts; i++) {
1949
+ const result = await attempt(i);
1950
+ if (!result.failed) return { recovered: true, attempts: i };
1951
+ }
1952
+ return { recovered: false, attempts: maxAttempts };
1953
+ }
1954
+
1955
+ // src/events.ts
1956
+ function isRecord(value) {
1957
+ return typeof value === "object" && value !== null;
1958
+ }
1959
+ function isToolUseBlock(block) {
1960
+ return isRecord(block) && block["type"] === "tool_use" && typeof block["id"] === "string" && typeof block["name"] === "string";
1961
+ }
1962
+ function isToolResultBlock(block) {
1963
+ return isRecord(block) && block["type"] === "tool_result" && typeof block["tool_use_id"] === "string";
1964
+ }
1965
+ var SUMMARY_MAX_LENGTH = 240;
1966
+ function truncate(text, max = SUMMARY_MAX_LENGTH) {
1967
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
1968
+ }
1969
+ function summarizeResultContent(content) {
1970
+ if (typeof content === "string") return truncate(content);
1971
+ if (Array.isArray(content)) {
1972
+ const text = content.map((b) => isRecord(b) && typeof b["text"] === "string" ? b["text"] : "").filter((s) => s.length > 0).join(" ");
1973
+ if (text.length > 0) return truncate(text);
1974
+ }
1975
+ return truncate(JSON.stringify(content ?? null));
1976
+ }
1977
+ var ChatEventMapper = class {
1978
+ stepId;
1979
+ stepStarted = false;
1980
+ pendingToolNames = /* @__PURE__ */ new Map();
1981
+ /** `name` becomes the enclosing step's `name` (the dispatched verb, e.g. "answer_query"). */
1982
+ constructor(name) {
1983
+ this.stepId = name;
1984
+ }
1985
+ /** Feed one SDK message; returns the events it produces, in arrival order. */
1986
+ next(message) {
1987
+ if (message.type === "assistant") return this.onAssistant(message);
1988
+ if (message.type === "user") return this.onUser(message);
1989
+ return [];
1990
+ }
1991
+ /** Call once the turn is done (successfully or not) to close the enclosing step, if one was opened. */
1992
+ finish(ok, detail) {
1993
+ if (!this.stepStarted) return [];
1994
+ return [{ t: "step_finish", id: this.stepId, ok, ...detail !== void 0 ? { detail } : {} }];
1995
+ }
1996
+ startStepIfNeeded() {
1997
+ if (this.stepStarted) return [];
1998
+ this.stepStarted = true;
1999
+ return [{ t: "step_start", id: this.stepId, name: this.stepId, parent: null, depth: 0 }];
2000
+ }
2001
+ onAssistant(message) {
2002
+ const content = message.message?.content;
2003
+ if (!Array.isArray(content)) return [];
2004
+ const parent = message.parent_tool_use_id ?? null;
2005
+ const depth = parent ? 1 : 0;
2006
+ const events = [];
2007
+ for (const block of content) {
2008
+ if (!isToolUseBlock(block)) continue;
2009
+ events.push(...this.startStepIfNeeded());
2010
+ this.pendingToolNames.set(block.id, block.name);
2011
+ events.push({
2012
+ t: "tool_call",
2013
+ id: block.id,
2014
+ name: block.name,
2015
+ ...block.input !== void 0 ? { input: block.input } : {},
2016
+ parent,
2017
+ depth
2018
+ });
2019
+ }
2020
+ return events;
2021
+ }
2022
+ onUser(message) {
2023
+ const content = message.message?.content;
2024
+ if (!Array.isArray(content)) return [];
2025
+ const events = [];
2026
+ for (const block of content) {
2027
+ if (!isToolResultBlock(block)) continue;
2028
+ const name = this.pendingToolNames.get(block.tool_use_id);
2029
+ if (name === void 0) continue;
2030
+ this.pendingToolNames.delete(block.tool_use_id);
2031
+ const ok = block.is_error !== true;
2032
+ const text = summarizeResultContent(block.content);
2033
+ events.push({
2034
+ t: "tool_result",
2035
+ id: block.tool_use_id,
2036
+ ok,
2037
+ ...ok ? { summary: text } : { error: text }
2038
+ });
2039
+ }
2040
+ return events;
2041
+ }
2042
+ };
2043
+
2044
+ // src/run.ts
2045
+ function isResult2(msg) {
2046
+ return msg.type === "result";
2047
+ }
2048
+ function isAssistant2(msg) {
2049
+ return msg.type === "assistant";
2050
+ }
2051
+ function aggregateTrace(messages, meta, denials) {
2052
+ const steps = messages.filter(isAssistant2).map((m) => ({
2053
+ model: m.message.model,
2054
+ parent_tool_use_id: m.parent_tool_use_id,
2055
+ usage: m.message.usage
2056
+ }));
2057
+ const result = messages.find(isResult2);
2058
+ const run = result === void 0 ? null : {
2059
+ total_cost_usd: result.total_cost_usd,
2060
+ duration_ms: result.duration_ms,
2061
+ duration_api_ms: result.duration_api_ms,
2062
+ num_turns: result.num_turns
2063
+ };
2064
+ return {
2065
+ target: meta.target,
2066
+ verb: meta.verb,
2067
+ model: meta.model,
2068
+ split: meta.split,
2069
+ run,
2070
+ usage: result?.usage ?? null,
2071
+ modelUsage: result?.modelUsage ?? {},
2072
+ steps,
2073
+ denials
2074
+ };
2075
+ }
2076
+ var DispatchSessionError = class extends DispatchError {
2077
+ constructor(message, sessionId) {
2078
+ super(message);
2079
+ this.sessionId = sessionId;
2080
+ this.name = "DispatchSessionError";
2081
+ }
2082
+ sessionId;
2083
+ };
2084
+ function realizeRender(gate, finalText, outPath, renderOpts) {
2085
+ try {
2086
+ renderEnvelope(finalText, outPath, renderOpts);
2087
+ return { htmlPath: outPath, renderDegraded: null };
2088
+ } catch (err) {
2089
+ if (gate.onFailure !== "degrade") throw err;
2090
+ const reason = err instanceof Error ? err.message : String(err);
2091
+ process.stderr.write(`warble-agent-sdk: render_contract degraded (best-effort) \u2014 ${reason}
2092
+ `);
2093
+ return { htmlPath: null, renderDegraded: { reason } };
2094
+ }
2095
+ }
2096
+ function requireFinalText2(result) {
2097
+ if (result === void 0) {
2098
+ throw new DispatchSessionError("the query() stream ended without a result message", null);
2099
+ }
2100
+ if (result.subtype !== "success") {
2101
+ throw new DispatchSessionError(
2102
+ `agent run failed (${result.subtype}): ${result.errors.join("; ")}`,
2103
+ result.session_id ?? null
2104
+ );
2105
+ }
2106
+ return result.result;
2107
+ }
2108
+ async function runDispatch(plan, cfg) {
2109
+ if (plan.meta.mode === "hybrid-staged") {
2110
+ return process.env["WARBLE_HYBRID_MODE"] === "tool" ? runHybridTool(plan, cfg) : runHybridStaged(plan, cfg);
2111
+ }
2112
+ mkdirSync2(cfg.outDir, { recursive: true });
2113
+ const cwd = plan.options.cwd ?? process.cwd();
2114
+ const gate = plan.meta.render;
2115
+ const writeScope = gate.kind === "realize" && gate.flavor === "prompt" ? gate.scope : null;
2116
+ const { canUseTool, denials, hooks } = makeReadOnlyGuard({
2117
+ readOnly: plan.meta.readOnly,
2118
+ writeScope,
2119
+ cwd,
2120
+ setupScope: plan.meta.setupScope
2121
+ });
2122
+ const venvBin = join3(cwd, ".venv", "bin");
2123
+ const pathEnv = existsSync2(venvBin) ? `${venvBin}:${process.env.PATH ?? ""}` : process.env.PATH ?? "";
2124
+ const env = { ...process.env, PATH: pathEnv };
2125
+ const options = {
2126
+ ...plan.options,
2127
+ canUseTool,
2128
+ // Read never reaches `canUseTool` for an in-cwd path in the real SDK (see guardrails.ts); this
2129
+ // hook is the live enforcement point for the +Setup dotenv-read gap's Read side.
2130
+ hooks: { ...plan.options.hooks, PreToolUse: [...plan.options.hooks?.PreToolUse ?? [], ...hooks] },
2131
+ env,
2132
+ ...cfg.resume ? { resume: cfg.resume } : {}
2133
+ };
2134
+ const mapper = new ChatEventMapper(plan.meta.verb);
2135
+ const messages = [];
2136
+ for await (const message of query2({ prompt: plan.prompt, options })) {
2137
+ messages.push(message);
2138
+ if (cfg.onEvent) for (const event of mapper.next(message)) cfg.onEvent(event);
2139
+ }
2140
+ const result = messages.find(isResult2);
2141
+ const finalText = requireFinalText2(result);
2142
+ if (cfg.onEvent) for (const event of mapper.finish(true)) cfg.onEvent(event);
2143
+ const trace = aggregateTrace(messages, plan.meta, denials);
2144
+ const sessionId = result?.session_id ?? null;
2145
+ writeFileSync3(join3(cfg.outDir, "result.txt"), finalText, "utf8");
2146
+ writeFileSync3(join3(cfg.outDir, "trace.json"), JSON.stringify(trace, null, 2) + "\n", "utf8");
2147
+ let htmlPath = null;
2148
+ let renderDegraded = null;
2149
+ if (gate.kind === "realize" && gate.flavor === "programmatic") {
2150
+ const out = join3(cfg.outDir, "dashboard.html");
2151
+ const realized = realizeRender(gate, finalText, out, {
2152
+ warbleBin: cfg.warbleBin,
2153
+ ...cfg.title ? { title: cfg.title } : {}
2154
+ });
2155
+ htmlPath = realized.htmlPath;
2156
+ renderDegraded = realized.renderDegraded;
2157
+ } else if (plan.meta.assertion) {
2158
+ const out = join3(cfg.outDir, "status.html");
2159
+ renderEnvelope(finalText, out, {
2160
+ warbleBin: cfg.warbleBin,
2161
+ ...cfg.title ? { title: cfg.title } : {}
2162
+ });
2163
+ htmlPath = out;
2164
+ }
2165
+ return { finalText, trace, htmlPath, denials, sessionId, renderDegraded };
2166
+ }
2167
+ function hybridCloudPreamble(cwd) {
2168
+ return [
2169
+ `You are bound to the wren project at \`${cwd}\` (your working directory).`,
2170
+ "All data access MUST go through the `wren` CLI \u2014 never raw SQL clients."
2171
+ ].join("\n");
2172
+ }
2173
+ function slotKey(step) {
2174
+ return step.produces ?? step.name;
2175
+ }
2176
+ async function executeStep(step, slots, ctx, tolerant) {
2177
+ const messages = buildStepMessages(step, ctx.plan.prompt, slots);
2178
+ const userPrompt = messages.find((m) => m.role === "user")?.content ?? ctx.plan.prompt;
2179
+ try {
2180
+ if (step.provider === "openai_compat") {
2181
+ if (!step.endpoint) throw new DispatchError(`local step '${step.name}' has no endpoint`);
2182
+ const text2 = await callOpenAiCompat({ endpoint: step.endpoint, model: step.model, messages });
2183
+ ctx.steps.push({ model: `openai_compat:${step.model}`, parent_tool_use_id: step.name, usage: null });
2184
+ process.stderr.write(`warble hybrid: step '${step.name}' \u2192 local ${step.model}
2185
+ `);
2186
+ return { outcome: "success", text: text2 };
2187
+ }
2188
+ const stepOptions = {
2189
+ cwd: ctx.cwd,
2190
+ permissionMode: "default",
2191
+ maxTurns: ctx.plan.options.maxTurns ?? 40,
2192
+ model: step.model,
2193
+ systemPrompt: `${hybridCloudPreamble(ctx.cwd)}
2194
+
2195
+ ${step.prompt}`,
2196
+ tools: ctx.plan.options.tools,
2197
+ allowedTools: ctx.plan.options.allowedTools,
2198
+ disallowedTools: ctx.plan.options.disallowedTools,
2199
+ canUseTool: ctx.canUseTool,
2200
+ // Read never reaches `canUseTool` for an in-cwd path in the real SDK (see guardrails.ts); this
2201
+ // hook is the live enforcement point for the +Setup dotenv-read gap's Read side.
2202
+ hooks: { PreToolUse: ctx.hooks },
2203
+ env: ctx.env
2204
+ };
2205
+ const msgs = [];
2206
+ for await (const message of query2({ prompt: userPrompt, options: stepOptions })) {
2207
+ msgs.push(message);
2208
+ }
2209
+ for (const m of msgs.filter(isAssistant2)) {
2210
+ ctx.steps.push({ model: m.message.model, parent_tool_use_id: step.name, usage: m.message.usage });
2211
+ }
2212
+ const result = msgs.find(isResult2);
2213
+ const text = requireFinalText2(result);
2214
+ if (result && result.subtype === "success") ctx.recordCost(result.total_cost_usd);
2215
+ process.stderr.write(`warble hybrid: step '${step.name}' \u2192 cloud ${step.model}
2216
+ `);
2217
+ return { outcome: "success", text };
2218
+ } catch (err) {
2219
+ if (!tolerant) throw err;
2220
+ const text = err instanceof Error ? err.message : String(err);
2221
+ process.stderr.write(
2222
+ `warble hybrid: step '${step.name}' failed (tolerant \u2014 a later guard depends on its outcome): ${text}
2223
+ `
2224
+ );
2225
+ return { outcome: "failure", text };
2226
+ }
2227
+ }
2228
+ async function runHybridStaged(plan, cfg) {
2229
+ mkdirSync2(cfg.outDir, { recursive: true });
2230
+ const cwd = plan.options.cwd ?? process.cwd();
2231
+ const { canUseTool, denials, hooks } = makeReadOnlyGuard({
2232
+ readOnly: plan.meta.readOnly,
2233
+ writeScope: null,
2234
+ cwd,
2235
+ setupScope: plan.meta.setupScope
2236
+ });
2237
+ const venvBin = join3(cwd, ".venv", "bin");
2238
+ const pathEnv = existsSync2(venvBin) ? `${venvBin}:${process.env.PATH ?? ""}` : process.env.PATH ?? "";
2239
+ const env = { ...process.env, PATH: pathEnv };
2240
+ const slots = {};
2241
+ const outcomes = {};
2242
+ const steps = [];
2243
+ let finalText = "";
2244
+ let totalCost = 0;
2245
+ const startedAll = Date.now();
2246
+ const execCtx = {
2247
+ cwd,
2248
+ canUseTool,
2249
+ hooks,
2250
+ env,
2251
+ plan,
2252
+ steps,
2253
+ recordCost: (cost) => {
2254
+ totalCost += cost;
2255
+ }
2256
+ };
2257
+ const stagedSteps = plan.meta.stagedSteps;
2258
+ const failureGuardTargets = /* @__PURE__ */ new Set();
2259
+ for (const s of stagedSteps) {
2260
+ if (s.conditional && s.when !== null && s.when.guard === "on_failure") {
2261
+ failureGuardTargets.add(s.when.target);
2262
+ }
2263
+ }
2264
+ for (let i = 0; i < stagedSteps.length; i++) {
2265
+ const step = stagedSteps[i];
2266
+ if (step.conditional) {
2267
+ if (step.when === null) {
2268
+ throw new DispatchError(`conditional step '${step.name}' has no 'when' guard`);
2269
+ }
2270
+ const preceding = i > 0 ? stagedSteps[i - 1] : null;
2271
+ const precedingIdentity = preceding === null ? null : { name: preceding.name, produces: preceding.produces };
2272
+ const decision = classifyConditionalStep(step.when, step.consumes, precedingIdentity, {
2273
+ slots,
2274
+ outcomes
2275
+ });
2276
+ if (decision.kind === "skip") {
2277
+ process.stderr.write(`warble hybrid: guard false \u2014 skipping conditional step '${step.name}'
2278
+ `);
2279
+ continue;
2280
+ }
2281
+ if (decision.kind === "repair") {
2282
+ let lastFailureText = slots[decision.target.produces ?? decision.target.name] ?? "";
2283
+ const { recovered, attempts } = await runRepairLoop(DEFAULT_MAX_REPAIR_ATTEMPTS, async () => {
2284
+ const attempt = await executeStep(step, slots, execCtx, true);
2285
+ outcomes[step.name] = attempt.outcome;
2286
+ slots[slotKey(step)] = attempt.text;
2287
+ if (attempt.outcome === "success") finalText = attempt.text;
2288
+ else lastFailureText = attempt.text;
2289
+ return { failed: attempt.outcome === "failure" };
2290
+ });
2291
+ if (!recovered) {
2292
+ throw new DispatchError(
2293
+ `repair step '${step.name}' did not recover '${decision.target.name}' after ${attempts} attempt(s); last failure: ${lastFailureText}`
2294
+ );
2295
+ }
2296
+ process.stderr.write(
2297
+ `warble hybrid: step '${step.name}' recovered '${decision.target.name}' (attempt ${attempts})
2298
+ `
2299
+ );
2300
+ continue;
2301
+ }
2302
+ }
2303
+ const outcome = await executeStep(step, slots, execCtx, failureGuardTargets.has(step.name));
2304
+ outcomes[step.name] = outcome.outcome;
2305
+ slots[slotKey(step)] = outcome.text;
2306
+ if (outcome.outcome === "success") finalText = outcome.text;
2307
+ }
2308
+ const trace = {
2309
+ target: plan.meta.target,
2310
+ verb: plan.meta.verb,
2311
+ model: plan.meta.model,
2312
+ split: false,
2313
+ run: {
2314
+ total_cost_usd: totalCost,
2315
+ duration_ms: Date.now() - startedAll,
2316
+ duration_api_ms: 0,
2317
+ num_turns: steps.length
2318
+ },
2319
+ usage: null,
2320
+ modelUsage: {},
2321
+ steps,
2322
+ denials
2323
+ };
2324
+ writeFileSync3(join3(cfg.outDir, "result.txt"), finalText, "utf8");
2325
+ writeFileSync3(join3(cfg.outDir, "trace.json"), JSON.stringify(trace, null, 2) + "\n", "utf8");
2326
+ return { finalText, trace, htmlPath: null, denials, sessionId: null, renderDegraded: null };
2327
+ }
2328
+
2329
+ // src/dispatch.ts
2330
+ import { dirname, isAbsolute, resolve } from "path";
2331
+ var UNAVAILABLE_COMPONENT_REASON = "component is unavailable on the configured runtime";
2332
+ function buildPreparedComponent(node, report, input, target, models) {
2333
+ const cfg = {
2334
+ target,
2335
+ flavor: input.flavor ?? DEFAULT_RENDER_FLAVOR,
2336
+ models,
2337
+ question: input.question ?? "",
2338
+ cwd: resolveProjectCwd(node, { ...input.project !== void 0 ? { project: input.project } : {}, ...input.irPath !== void 0 ? { irPath: input.irPath } : {} }),
2339
+ ...input.maxTurns !== void 0 ? { maxTurns: input.maxTurns } : {}
2340
+ };
2341
+ return { id: node.id, node, report, plan: buildDispatchPlan(node, report, cfg) };
2342
+ }
2343
+ function resolveProjectCwd(node, opts) {
2344
+ if (opts.project) return resolve(opts.project);
2345
+ const p = node.context_binding.project;
2346
+ if (isAbsolute(p)) return p;
2347
+ const baseDir = opts.irPath ? dirname(resolve(opts.irPath)) : process.cwd();
2348
+ return resolve(baseDir, p);
2349
+ }
2350
+ function prepareDispatch(input) {
2351
+ const ir = typeof input.ir === "string" ? parseIr(input.ir) : input.ir;
2352
+ assertSupportedIrVersion(ir.warble_ir_version);
2353
+ const target = input.target ?? DEFAULT_TARGET;
2354
+ const models = input.models ?? ModelConfig.default();
2355
+ models.validate(ir);
2356
+ let scoped = ir.components;
2357
+ if (input.componentId !== void 0) {
2358
+ const node = ir.components.find((candidate) => candidate.id === input.componentId);
2359
+ if (!node) {
2360
+ throw new DispatchError(
2361
+ `component '${input.componentId}' not found in IR (available: ${ir.components.map((c) => c.id).join(", ")})`
2362
+ );
2363
+ }
2364
+ scoped = [node];
2365
+ }
2366
+ const components = scoped.map((node) => {
2367
+ const report = resolveNodeCapabilities(node, target);
2368
+ return buildPreparedComponent(node, report, input, target, models);
2369
+ });
2370
+ return { target, components };
2371
+ }
2372
+ function prepareDisplayManifest(input) {
2373
+ const ir = typeof input.ir === "string" ? parseIr(input.ir) : input.ir;
2374
+ assertSupportedIrVersion(ir.warble_ir_version);
2375
+ const target = input.target ?? DEFAULT_TARGET;
2376
+ const models = input.models ?? ModelConfig.default();
2377
+ models.validate(ir);
2378
+ const components = ir.components.map((node) => {
2379
+ const report = inspectNodeCapabilities(node, target);
2380
+ if (report.some((entry2) => entry2.outcome === "fail")) {
2381
+ return { id: node.id, node, availability: { status: "unavailable", reason: UNAVAILABLE_COMPONENT_REASON } };
2382
+ }
2383
+ return buildPreparedComponent(node, report, input, target, models);
2384
+ });
2385
+ return { target, components };
2386
+ }
2387
+ async function dispatch(input, runCfg) {
2388
+ const prepared = prepareDispatch(input);
2389
+ const warbleBin = runCfg.warbleBin ?? "warble";
2390
+ const components = [];
2391
+ for (const c of prepared.components) {
2392
+ const result = await runDispatch(c.plan, {
2393
+ outDir: runCfg.outDir,
2394
+ warbleBin,
2395
+ ...runCfg.title ? { title: runCfg.title } : {}
2396
+ });
2397
+ components.push({ id: c.id, report: c.report, plan: c.plan, result });
2398
+ }
2399
+ return { target: prepared.target, components };
2400
+ }
2401
+
2402
+ // src/manifest.ts
2403
+ var MANIFEST_VERSION = "0.1";
2404
+ var MIN_SUPPORTED_IR_VERSION = "0.6";
2405
+ var MAX_SUPPORTED_IR_VERSION = "0.6";
2406
+ var DEFAULT_MAX_ATTEMPTS = 1;
2407
+ function classifyStep(node, stepIndex) {
2408
+ const when = node.llm_calls[stepIndex].when;
2409
+ if (!when) return { kind: "independent" };
2410
+ const adjacentPreceding = stepIndex > 0 && when.guard === "on_failure" && node.llm_calls[stepIndex - 1].name === when.target;
2411
+ if (adjacentPreceding) {
2412
+ return { kind: "repair_fold", fold_into: when.target, max_attempts: DEFAULT_MAX_ATTEMPTS };
2413
+ }
2414
+ return { kind: "guarded_skip" };
2415
+ }
2416
+ function buildStep(node, stepIndex) {
2417
+ const call = node.llm_calls[stepIndex];
2418
+ return {
2419
+ name: call.name,
2420
+ tier: call.tier,
2421
+ consumes: call.consumes,
2422
+ ...call.produces !== null ? { produces: call.produces } : {},
2423
+ prompt: call.prompt,
2424
+ ...call.when ? { when: { guard: call.when.guard, target: call.when.target } } : {},
2425
+ realization: classifyStep(node, stepIndex)
2426
+ };
2427
+ }
2428
+ function enforcementFor(name, hasThreshold) {
2429
+ if (name === "read_only_execution") return "read_only";
2430
+ if (name === "artifact_write") return "scoped_write";
2431
+ if (name.includes("_limit") || name.endsWith("_gate") || name === "deterministic_gate" || name === "additivity_guard") {
2432
+ return hasThreshold ? "threshold_limit" : "gated_check";
2433
+ }
2434
+ return "generic";
2435
+ }
2436
+ function guardrailManifest(g) {
2437
+ return {
2438
+ enforcement: enforcementFor(g.name, g.threshold !== void 0 && g.threshold !== null),
2439
+ locked: g.locked,
2440
+ ...g.scope !== null ? { scope: g.scope } : {},
2441
+ ...g.threshold !== void 0 && g.threshold !== null ? { threshold: g.threshold } : {}
2442
+ };
2443
+ }
2444
+ function buildGuardrails(node) {
2445
+ const out = {};
2446
+ for (const g of [...node.guardrails].sort((a, b) => a.name.localeCompare(b.name))) {
2447
+ out[g.name] = guardrailManifest(g);
2448
+ }
2449
+ return out;
2450
+ }
2451
+ var PRIMITIVES = /* @__PURE__ */ new Set(["string", "number", "boolean", "row"]);
2452
+ function primitiveSchema(name) {
2453
+ switch (name) {
2454
+ case "string":
2455
+ return { type: "string" };
2456
+ case "number":
2457
+ return { type: "number" };
2458
+ case "boolean":
2459
+ return { type: "boolean" };
2460
+ case "row":
2461
+ return { type: "object" };
2462
+ default:
2463
+ return { type: "string", enum: [name] };
2464
+ }
2465
+ }
2466
+ function makeNullable(schema) {
2467
+ const type = schema["type"];
2468
+ if (type === void 0) return schema;
2469
+ let widened;
2470
+ if (typeof type === "string") {
2471
+ widened = [type, "null"];
2472
+ } else if (Array.isArray(type)) {
2473
+ widened = type.includes("null") ? type : [...type, "null"];
2474
+ } else {
2475
+ widened = type;
2476
+ }
2477
+ return { ...schema, type: widened };
2478
+ }
2479
+ function fieldTypeToSchema(typeStr) {
2480
+ const nullable = typeStr.endsWith("?");
2481
+ const base = nullable ? typeStr.slice(0, -1) : typeStr;
2482
+ let schema;
2483
+ if (base.endsWith("[]")) {
2484
+ schema = { type: "array", items: primitiveSchema(base.slice(0, -2)) };
2485
+ } else if (base.includes("|")) {
2486
+ const alternatives = base.split("|");
2487
+ schema = alternatives.every((alt) => PRIMITIVES.has(alt)) ? { type: alternatives } : { type: "string", enum: alternatives };
2488
+ } else {
2489
+ schema = primitiveSchema(base);
2490
+ }
2491
+ return nullable ? makeNullable(schema) : schema;
2492
+ }
2493
+ function renderBlockSchema(block) {
2494
+ const properties = { type: { const: block.type } };
2495
+ const required = ["type"];
2496
+ for (const [name, typeStr] of Object.entries(block.fields).sort(([a], [b]) => a.localeCompare(b))) {
2497
+ properties[name] = fieldTypeToSchema(typeStr);
2498
+ if (!typeStr.endsWith("?")) required.push(name);
2499
+ }
2500
+ return { type: "object", properties, required };
2501
+ }
2502
+ function outputSchemaFor(effect) {
2503
+ const blockSchemas = effect.render_blocks.map(renderBlockSchema);
2504
+ const blocksItems = blockSchemas.length === 0 ? { type: "object" } : blockSchemas.length === 1 ? blockSchemas[0] : { anyOf: blockSchemas };
2505
+ return {
2506
+ type: "object",
2507
+ properties: {
2508
+ blocks: { type: "array", items: blocksItems },
2509
+ summary: { type: ["string", "null"] },
2510
+ verified: { type: ["boolean", "null"] }
2511
+ },
2512
+ required: ["blocks"]
2513
+ };
2514
+ }
2515
+ var LOCAL_TOOL_MAP = {
2516
+ "sql_execution:read_only": { name: "wren_query", source: "bash-wren" },
2517
+ genbi_build: { name: "wren_build", source: "bash-wren" },
2518
+ semantic_introspection: { name: "wren_context_show", source: "bash-wren" },
2519
+ raw_material_read: { name: "read_raw_material", source: "sdk-read" },
2520
+ schema_introspection: { name: "wren_context_show", source: "bash-wren" },
2521
+ source_connect: { name: "wren_connect", source: "bash-setup" },
2522
+ context_build: { name: "wren_context_build", source: "bash-setup" },
2523
+ artifact_write: { name: "write_artifact", source: "fs" },
2524
+ version_control: { name: "commit", source: "git" },
2525
+ scheduler: { name: "schedule", source: "os-cron" },
2526
+ event_bus: { name: "publish_event", source: "pub-sub" },
2527
+ notify_channel: { name: "notify", source: "mcp-notify" }
2528
+ };
2529
+ function buildTools2(node) {
2530
+ const seen = /* @__PURE__ */ new Set();
2531
+ const out = [];
2532
+ for (const capability of collectRequiredCapabilities(node)) {
2533
+ const binding = LOCAL_TOOL_MAP[capability];
2534
+ if (!binding || seen.has(binding.name)) continue;
2535
+ seen.add(binding.name);
2536
+ out.push(binding);
2537
+ }
2538
+ return out;
2539
+ }
2540
+ function buildAgentManifest(component) {
2541
+ const node = component.node;
2542
+ return {
2543
+ id: node.id,
2544
+ verb: node.verb,
2545
+ component_type: node.type,
2546
+ realization_kind: node.realization_kind,
2547
+ trigger: node.trigger.kind,
2548
+ outcome: node.effect.outcome.kind,
2549
+ steps: node.llm_calls.map((_call, i) => buildStep(node, i)),
2550
+ guardrails: buildGuardrails(node),
2551
+ tools: buildTools2(node),
2552
+ output_schema: outputSchemaFor(node.effect),
2553
+ capabilities: component.report,
2554
+ ...node.brief !== void 0 ? { brief: node.brief } : {}
2555
+ };
2556
+ }
2557
+ function buildUnavailableAgentManifest(component) {
2558
+ const node = component.node;
2559
+ return {
2560
+ id: node.id,
2561
+ verb: node.verb,
2562
+ component_type: node.type,
2563
+ realization_kind: node.realization_kind,
2564
+ trigger: node.trigger.kind,
2565
+ outcome: node.effect.outcome.kind,
2566
+ steps: [],
2567
+ guardrails: {},
2568
+ tools: [],
2569
+ output_schema: {},
2570
+ capabilities: [],
2571
+ availability: component.availability
2572
+ };
2573
+ }
2574
+ function buildManifest(prepared, raw) {
2575
+ const ir = parseIr(raw);
2576
+ return {
2577
+ manifest_version: MANIFEST_VERSION,
2578
+ compat: { min_ir_version: MIN_SUPPORTED_IR_VERSION, max_ir_version: MAX_SUPPORTED_IR_VERSION },
2579
+ profile: ir.profile,
2580
+ target: prepared.target,
2581
+ agents: prepared.components.map((component) => "availability" in component ? buildUnavailableAgentManifest(component) : buildAgentManifest(component))
2582
+ };
2583
+ }
2584
+
2585
+ // src/model_catalog.ts
2586
+ import { query as query3 } from "@anthropic-ai/claude-agent-sdk";
2587
+ import { resolve as resolve2 } from "path";
2588
+ var MODEL_CATALOG_VERSION = 1;
2589
+ var CLEANUP_GRACE_MS = 25;
2590
+ function unavailable(code, retryable) {
2591
+ return { version: MODEL_CATALOG_VERSION, status: "unavailable", provider: "claude", code, retryable };
2592
+ }
2593
+ function classify(error) {
2594
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
2595
+ if (message.includes("timed out")) return unavailable("timeout", true);
2596
+ if (/(not authenticated|unauthenticated|authentication|login required|sign in)/.test(message)) {
2597
+ return unavailable("not_authenticated", false);
2598
+ }
2599
+ if (/(enoent|failed to start|not found|runtime unavailable)/.test(message)) {
2600
+ return unavailable("runtime_unavailable", true);
2601
+ }
2602
+ return unavailable("protocol_error", false);
2603
+ }
2604
+ async function* idleInput() {
2605
+ }
2606
+ function mapModel(model) {
2607
+ if (typeof model.value !== "string" || typeof model.displayName !== "string") {
2608
+ throw new Error("malformed model catalog response");
2609
+ }
2610
+ return {
2611
+ model: model.value,
2612
+ displayName: model.displayName,
2613
+ ...typeof model.description === "string" && model.description.length > 0 ? { description: model.description } : {}
2614
+ };
2615
+ }
2616
+ async function withinTimeout(operation, timeoutMs, abortController) {
2617
+ let timer;
2618
+ try {
2619
+ return await Promise.race([
2620
+ operation,
2621
+ new Promise((_, reject) => {
2622
+ timer = setTimeout(() => {
2623
+ abortController.abort();
2624
+ reject(new Error("model catalog timed out"));
2625
+ }, timeoutMs);
2626
+ })
2627
+ ]);
2628
+ } finally {
2629
+ if (timer !== void 0) clearTimeout(timer);
2630
+ }
2631
+ }
2632
+ async function settleCleanup(operation) {
2633
+ let timer;
2634
+ try {
2635
+ await Promise.race([
2636
+ operation.catch(() => void 0),
2637
+ new Promise((resolve3) => {
2638
+ timer = setTimeout(resolve3, CLEANUP_GRACE_MS);
2639
+ })
2640
+ ]);
2641
+ } finally {
2642
+ if (timer !== void 0) clearTimeout(timer);
2643
+ }
2644
+ }
2645
+ async function discoverClaudeModels(options = {}) {
2646
+ const abortController = new AbortController();
2647
+ const timeoutMs = options.timeoutMs ?? 1e4;
2648
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return unavailable("protocol_error", false);
2649
+ let catalogQuery;
2650
+ try {
2651
+ const queryFactory = options.queryFactory ?? query3;
2652
+ catalogQuery = queryFactory({
2653
+ prompt: idleInput(),
2654
+ options: {
2655
+ cwd: resolve2(options.cwd ?? process.cwd()),
2656
+ tools: [],
2657
+ mcpServers: [],
2658
+ settingSources: [],
2659
+ abortController
2660
+ }
2661
+ });
2662
+ const models = await withinTimeout(catalogQuery.supportedModels(), timeoutMs, abortController);
2663
+ if (!Array.isArray(models)) throw new Error("malformed model catalog response");
2664
+ return {
2665
+ version: MODEL_CATALOG_VERSION,
2666
+ status: "ready",
2667
+ provider: "claude",
2668
+ models: models.map(mapModel)
2669
+ };
2670
+ } catch (error) {
2671
+ return classify(error);
2672
+ } finally {
2673
+ abortController.abort();
2674
+ if (catalogQuery !== void 0) {
2675
+ await Promise.all([
2676
+ settleCleanup(catalogQuery.interrupt()),
2677
+ settleCleanup(catalogQuery.return())
2678
+ ]);
2679
+ }
2680
+ }
2681
+ }
2682
+
2683
+ // src/session.ts
2684
+ import { mkdirSync as mkdirSync3 } from "fs";
2685
+ import { join as join4 } from "path";
2686
+ var BREAKDOWN_RE = /\bby\s+([a-z][a-z0-9_]*(?:\s*(?:,|and)\s*[a-z][a-z0-9_]*)*)/i;
2687
+ var FILTER_OVERRIDE_RE = /\b(where|only|instead of|excluding|filtered?\s+to)\b/i;
2688
+ function extractBreakdownDimensions(question) {
2689
+ const m = question.match(BREAKDOWN_RE);
2690
+ if (!m) return null;
2691
+ const dims = m[1].split(/\s*(?:,|and)\s*/i).map((s) => s.trim()).filter((s) => s.length > 0);
2692
+ return dims.length > 0 ? dims : null;
2693
+ }
2694
+ function distillFollowup(prevIntent, newQuestion) {
2695
+ const newDimensions = extractBreakdownDimensions(newQuestion);
2696
+ const dimensions = newDimensions ?? prevIntent.dimensions;
2697
+ const filters = FILTER_OVERRIDE_RE.test(newQuestion) ? [] : prevIntent.filters;
2698
+ const carried = [];
2699
+ if (filters.length > 0) carried.push(`filter(s): ${filters.join(", ")}`);
2700
+ if (dimensions.length > 0) {
2701
+ carried.push(
2702
+ newDimensions ? `breakdown swapped to: ${dimensions.join(", ")} (was: ${prevIntent.dimensions.join(", ") || "none"})` : `dimension(s): ${dimensions.join(", ")}`
2703
+ );
2704
+ }
2705
+ if (prevIntent.measures.length > 0) carried.push(`measure(s): ${prevIntent.measures.join(", ")}`);
2706
+ if (prevIntent.grain) carried.push(`grain: ${prevIntent.grain}`);
2707
+ if (carried.length === 0) return newQuestion;
2708
+ return [
2709
+ "[Context carried from the previous turn \u2014 reuse it unless this question overrides it; you still decide the actual resolution.]",
2710
+ carried.map((c) => `- ${c}`).join("\n"),
2711
+ "",
2712
+ newQuestion
2713
+ ].join("\n");
2714
+ }
2715
+ var DEFAULT_CLARIFY_THRESHOLD = 0.55;
2716
+ function decideClarify(question, confidence, threshold = DEFAULT_CLARIFY_THRESHOLD) {
2717
+ if (confidence < threshold) {
2718
+ return {
2719
+ kind: "clarify",
2720
+ question: `I want to make sure I answer "${question}" correctly \u2014 could you clarify which metric, filter, or time range you mean?`
2721
+ };
2722
+ }
2723
+ return { kind: "answer" };
2724
+ }
2725
+ function createSessionState() {
2726
+ return { turns: [] };
2727
+ }
2728
+ function lastTurn(state) {
2729
+ return state.turns[state.turns.length - 1];
2730
+ }
2731
+ function lastSessionId(state) {
2732
+ return lastTurn(state)?.sessionId ?? null;
2733
+ }
2734
+ function lastResolvedIntent(state) {
2735
+ return lastTurn(state)?.intent ?? null;
2736
+ }
2737
+ function appendTurn(state, turn) {
2738
+ return { turns: [...state.turns, turn] };
2739
+ }
2740
+ function buildTurnPrompt(state, question) {
2741
+ const prevIntent = lastResolvedIntent(state);
2742
+ return prevIntent ? distillFollowup(prevIntent, question) : question;
2743
+ }
2744
+ var ChatSession = class {
2745
+ constructor(plan, runCfg, initialResumeSessionId) {
2746
+ this.plan = plan;
2747
+ this.runCfg = runCfg;
2748
+ this.initialResumeSessionId = initialResumeSessionId;
2749
+ }
2750
+ plan;
2751
+ runCfg;
2752
+ initialResumeSessionId;
2753
+ state = createSessionState();
2754
+ getState() {
2755
+ return this.state;
2756
+ }
2757
+ async ask(question, opts = {}) {
2758
+ const prompt = buildTurnPrompt(this.state, question);
2759
+ const resume = lastSessionId(this.state) ?? this.initialResumeSessionId ?? null;
2760
+ const turnPlan = { ...this.plan, prompt };
2761
+ const turnOutDir = join4(this.runCfg.outDir, `turn-${this.state.turns.length + 1}`);
2762
+ mkdirSync3(turnOutDir, { recursive: true });
2763
+ const result = await runDispatch(turnPlan, {
2764
+ ...this.runCfg,
2765
+ outDir: turnOutDir,
2766
+ ...resume ? { resume } : {},
2767
+ ...opts.onEvent ? { onEvent: opts.onEvent } : {}
2768
+ });
2769
+ this.state = appendTurn(this.state, {
2770
+ question,
2771
+ prompt,
2772
+ intent: opts.intent ?? null,
2773
+ sessionId: result.sessionId,
2774
+ finalText: result.finalText
2775
+ });
2776
+ return { finalText: result.finalText, sessionId: result.sessionId, trace: result.trace, prompt };
2777
+ }
2778
+ };
2779
+ function createChatSession(plan, runCfg, initialResumeSessionId) {
2780
+ return new ChatSession(plan, runCfg, initialResumeSessionId);
2781
+ }
2782
+
2783
+ export {
2784
+ DispatchError,
2785
+ emitAgentModule,
2786
+ REALIZATION_KINDS,
2787
+ COMPONENT_TYPES,
2788
+ TRIGGER_KINDS,
2789
+ OUTCOME_KINDS,
2790
+ SUPPORTED_IR_VERSIONS,
2791
+ parseIr,
2792
+ distinctTiers,
2793
+ ModelConfig,
2794
+ resolveStagedSteps,
2795
+ distinctProviders,
2796
+ usesLocalProvider,
2797
+ planProviderRouting,
2798
+ buildStepMessages,
2799
+ DEFAULT_TARGET,
2800
+ isKnownTarget,
2801
+ knownTargetNames,
2802
+ localProfile,
2803
+ profileFor,
2804
+ DEFAULT_RENDER_FLAVOR,
2805
+ parseRenderFlavor,
2806
+ DESTRUCTIVE_BASH_DENY,
2807
+ shouldSplitPerStepTier,
2808
+ buildDispatchPlan,
2809
+ collectRequiredCapabilities,
2810
+ resolveCapabilities,
2811
+ resolveNodeCapabilities,
2812
+ inspectNodeCapabilities,
2813
+ makeReadOnlyGuard,
2814
+ buildChatRequest,
2815
+ extractCompletionText,
2816
+ callOpenAiCompat,
2817
+ buildToolDriverPrompt,
2818
+ runHybridTool,
2819
+ renderEnvelope,
2820
+ aggregateTrace,
2821
+ DispatchSessionError,
2822
+ runDispatch,
2823
+ UNAVAILABLE_COMPONENT_REASON,
2824
+ resolveProjectCwd,
2825
+ prepareDispatch,
2826
+ prepareDisplayManifest,
2827
+ dispatch,
2828
+ buildAgentManifest,
2829
+ buildUnavailableAgentManifest,
2830
+ buildManifest,
2831
+ MODEL_CATALOG_VERSION,
2832
+ discoverClaudeModels,
2833
+ distillFollowup,
2834
+ DEFAULT_CLARIFY_THRESHOLD,
2835
+ decideClarify,
2836
+ createSessionState,
2837
+ lastSessionId,
2838
+ lastResolvedIntent,
2839
+ appendTurn,
2840
+ buildTurnPrompt,
2841
+ ChatSession,
2842
+ createChatSession
2843
+ };
2844
+ //# sourceMappingURL=chunk-F4QDKVP7.js.map