warble-claude-code 0.15.1

Warble back-end for the Claude Code CLI target: IR → static agent files + reference renderer + capability manifest.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
//! Hybrid (`llm:per_step_provider`) file-target realization: LOCAL steps run on a non-Anthropic
//! provider via either an emitted Bash local-inference script or a `warble mcp-serve` MCP tool, while
//! CLOUD steps stay the driver's own `wren` work.

use super::agent::{to_yaml, AgentFrontmatter};
use super::fs_util::{mkdir_all, write_json};
use super::gate::build_description;
use super::scope::merge_scope_settings;
use super::settings::wren_config;
use super::support::{
    find_guardrail, outcome_supported, realization_supported, trigger_supported, unsupported,
    ARTIFACT_WRITE_GUARDRAIL_NAME, DESTRUCTIVE_BASH_DENY_PATTERNS,
};
use super::types::{ContextInjection, HybridRealization};
use crate::error::DispatchError;
use crate::ir::{LlmCall, WarbleIr};
use crate::models::{ModelConfig, ANTHROPIC_PROVIDER};
use std::fs;
use std::path::Path;

/// Emit Claude Code agent runtime files for a resolved IR into `out_dir`, using the default tier →
/// model binding (`strong→opus`, `cheap→haiku`, orchestrator `sonnet`). See
/// [`emit_claude_code_with_models`] to override the mapping at dispatch.
/// True if any step in the IR binds to a non-Anthropic provider (i.e. the binding is hybrid). The
/// dispatch takes the bash-script path in that case (only valid once the provider gate has passed).
pub(super) fn any_local_provider(
    ir: &WarbleIr,
    models: &ModelConfig,
) -> Result<bool, DispatchError> {
    for node in &ir.components {
        for call in &node.llm_calls {
            if models.binding(&call.tier)?.provider != ANTHROPIC_PROVIDER {
                return Ok(true);
            }
        }
    }
    Ok(false)
}

/// Profile-level disclosure shared by the scope prompt and RUN.md. Hybrid routing changes how the
/// profile is realized; it does not turn the materialized directory into a component-scoped output.
pub(super) fn build_hybrid_disclosure(
    ir: &WarbleIr,
    models: &ModelConfig,
    realization: HybridRealization,
) -> Result<String, DispatchError> {
    let mut lines = vec![
        "## Hybrid provider routing".to_string(),
        String::new(),
        match realization {
            HybridRealization::BashScript => "- Realization: `bash-script`. Dispatch emits `scripts/local_infer.py`, one `scripts/<component>__<step>.sh` wrapper and `.system.txt` prompt per LOCAL step; successful local calls append to `hybrid-trace.jsonl`.".to_string(),
            HybridRealization::McpServer => "- Realization: `mcp-server`. Dispatch emits `.mcp.json` plus `mcp-steps.json`; each LOCAL step runs through the `local_infer` MCP tool.".to_string(),
        },
    ];
    for node in &ir.components {
        for call in &node.llm_calls {
            let binding = models.binding(&call.tier)?;
            if binding.provider == ANTHROPIC_PROVIDER {
                lines.push(format!(
                    "- `{}.{}`: CLOUD provider `{}`, model `{}`; the `{}` driver executes it.",
                    node.verb, call.name, binding.provider, binding.model, node.verb
                ));
            } else {
                let mechanism = match realization {
                    HybridRealization::BashScript => {
                        format!("`scripts/{}__{}.sh`", node.verb, call.name)
                    }
                    HybridRealization::McpServer => "the `local_infer` MCP tool".to_string(),
                };
                let endpoint = binding.endpoint.as_deref().unwrap_or("<missing endpoint>");
                lines.push(format!(
                    "- `{}.{}`: LOCAL provider `{}`, model `{}`, endpoint `{}`; runs via {}.",
                    node.verb, call.name, binding.provider, binding.model, endpoint, mechanism
                ));
            }
        }
    }
    lines.push(String::new());
    Ok(lines.join("\n"))
}

/// Human-facing run document for a hybrid file-target profile, written once beside the one scope
/// prompt and one session envelope.
pub(super) fn build_hybrid_run_md(ir: &WarbleIr, target_id: &str, disclosure: &str) -> String {
    let mut lines = vec![
        format!("# Running `{}`", ir.profile),
        String::new(),
        "Run each agent from this directory so the profile-level `.claude/`, `.wren/`, and hybrid artifacts are loaded together.".to_string(),
        String::new(),
        disclosure.to_string(),
    ];
    for node in &ir.components {
        lines.extend([
            format!("## `{}`", node.verb),
            String::new(),
            "```sh".to_string(),
            if target_id == "claude-code:interactive" {
                format!("claude --agent {}", node.verb)
            } else {
                format!("claude -p \"<data question>\" --agent {}", node.verb)
            },
            "```".to_string(),
            String::new(),
            "The driver follows the provider routing above in order and marshals each step's output into the next step.".to_string(),
            String::new(),
        ]);
    }
    lines.join("\n")
}

/// The generic local-inference helper the emitted bash-script scripts call (OpenAI-compatible chat,
/// e.g. ollama). Pure stdlib (urllib) so it runs under any python3; no deps to install.
const LOCAL_INFER_PY: &str = r#"#!/usr/bin/env python3
# Emitted by `warble dispatch` for the hybrid (bash-script) file target. Calls an OpenAI-compatible
# chat endpoint (e.g. ollama) for ONE step that a binding routed to a local provider. On success it
# appends a per-step line to --trace (JSONL) so a run is self-evidencing: which steps actually ran on
# which local model. (Cloud steps run inside `claude`, not here, so they are not in this trace.)
import argparse, json, sys, time, urllib.request
p = argparse.ArgumentParser()
p.add_argument("--step", default="")
p.add_argument("--endpoint", required=True)
p.add_argument("--model", required=True)
p.add_argument("--system-file", required=True)
p.add_argument("--trace", default="")
p.add_argument("--user", required=True)
a = p.parse_args()
system = open(a.system_file, encoding="utf-8").read()
body = json.dumps({
    "model": a.model,
    "messages": [{"role": "system", "content": system}, {"role": "user", "content": a.user}],
    "stream": False, "temperature": 0,
}).encode()
req = urllib.request.Request(a.endpoint.rstrip("/") + "/chat/completions", data=body,
                            headers={"content-type": "application/json"})
try:
    resp = json.load(urllib.request.urlopen(req, timeout=120))
    content = resp["choices"][0]["message"]["content"]
except Exception as e:  # loud-fail so the orchestrator sees it, never a silent empty step
    sys.stderr.write("local_infer error: %s\n" % e)
    sys.exit(1)
sys.stdout.write(content)
if a.trace:
    try:
        with open(a.trace, "a", encoding="utf-8") as f:
            f.write(json.dumps({
                "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "step": a.step,
                "provider": "openai_compat", "model": a.model, "endpoint": a.endpoint,
                "input_chars": len(a.user), "output_chars": len(content),
            }) + "\n")
    except Exception:
        pass  # trace is best-effort; never fail the step over it
"#;

/// Emit the hybrid (bash-script) realization of `llm:per_step_provider` for the file target: the LOCAL
/// step(s) become an emitted local-inference script the driver runs via Bash; the CLOUD steps stay the
/// driver's own `wren` work at its (strong) tier. POC scope: render-none analytical one_shot components
/// (answer_query), with a single cloud tier hosting the driver. Anything else loud-fails.
pub(super) fn emit_hybrid_file_target(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    models: &ModelConfig,
    context: &ContextInjection,
) -> Result<(), DispatchError> {
    let claude_dir = out_dir.join(".claude");
    let agents_dir = claude_dir.join("agents");
    let scripts_dir = out_dir.join("scripts");
    let wren_dir = out_dir.join(".wren");
    mkdir_all(&agents_dir)?;
    mkdir_all(&scripts_dir)?;
    mkdir_all(&wren_dir)?;
    fs::write(scripts_dir.join("local_infer.py"), LOCAL_INFER_PY)
        .map_err(|e| DispatchError(format!("write local_infer.py: {e}")))?;
    let scripts_abs = fs::canonicalize(&scripts_dir)
        .map_err(|e| DispatchError(format!("canonicalize scripts dir: {e}")))?;

    for node in &ir.components {
        if !realization_supported(node.realization_kind) {
            return Err(unsupported(
                "realization_kind",
                node.realization_kind.as_str(),
            ));
        }
        if !trigger_supported(node.trigger.kind) {
            return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
        }
        if !outcome_supported(node.effect.outcome.kind) {
            return Err(unsupported(
                "outcome.kind",
                node.effect.outcome.kind.as_str(),
            ));
        }
        if !node.effect.render_blocks.is_empty()
            && find_guardrail(&node.guardrails, ARTIFACT_WRITE_GUARDRAIL_NAME).is_some()
        {
            return Err(DispatchError(format!(
                "hybrid bash-script file target does not yet realize a render gate for '{}' \
(wall-hit); POC covers render-none components like answer_query",
                node.verb
            )));
        }

        let cloud_calls: Vec<LlmCall> = node
            .llm_calls
            .iter()
            .filter(|c| {
                models
                    .binding(&c.tier)
                    .map(|b| b.provider == ANTHROPIC_PROVIDER)
                    .unwrap_or(false)
            })
            .cloned()
            .collect();
        if cloud_calls.is_empty() {
            return Err(DispatchError(format!(
                "hybrid file target needs at least one cloud (Anthropic) step to host the driver, \
but every step of '{}' is bound to a local provider",
                node.verb
            )));
        }
        let driver_model = models.collapsed_model(&cloud_calls)?.to_string();

        // Emit a wrapper + system-prompt file for each LOCAL step.
        let mut step_lines: Vec<String> = Vec::new();
        for (i, call) in node.llm_calls.iter().enumerate() {
            let n = i + 1;
            let binding = models.binding(&call.tier)?;
            let consumes_note = if call.consumes.is_empty() {
                String::new()
            } else {
                format!(
                    " It needs {} from the earlier step(s).",
                    call.consumes.join(", ")
                )
            };
            let produces_note = call
                .produces
                .as_ref()
                .map(|p| format!(" Its output is `{p}`."))
                .unwrap_or_default();
            if binding.provider != ANTHROPIC_PROVIDER {
                let endpoint = binding.endpoint.as_deref().ok_or_else(|| {
                    DispatchError(format!("local step '{}' has no endpoint", call.name))
                })?;
                let base = format!("{}__{}", node.verb, call.name);
                fs::write(
                    scripts_dir.join(format!("{base}.system.txt")),
                    format!("{}\n\n{}", context.prompt_section(), call.prompt),
                )
                .map_err(|e| DispatchError(format!("write system file: {e}")))?;
                let wrapper = format!(
                    r#"#!/usr/bin/env bash
# LOCAL step '{name}' (tier '{tier}', provider {provider}). $1 = the marshaled input text.
here="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$here/local_infer.py" \
  --step '{name}' --endpoint '{endpoint}' --model '{model}' \
  --system-file "$here/{base}.system.txt" \
  --trace "$here/../hybrid-trace.jsonl" --user "$1"
"#,
                    name = call.name,
                    tier = call.tier,
                    provider = binding.provider.as_str(),
                    endpoint = endpoint,
                    model = binding.model,
                    base = base
                );
                let wrapper_path = scripts_dir.join(format!("{base}.sh"));
                fs::write(&wrapper_path, wrapper)
                    .map_err(|e| DispatchError(format!("write wrapper: {e}")))?;
                step_lines.push(format!(
                    "{n}. `{}` — runs on a LOCAL model.{consumes_note} Execute exactly (substituting \
the marshaled input for INPUT):\n   `bash {}/{base}.sh \"INPUT\"`\n   Capture its stdout.{produces_note}",
                    call.name,
                    scripts_abs.display()
                ));
            } else {
                let cond = if call.conditional {
                    " (only if the previous step's result failed validation and needs repair)"
                } else {
                    ""
                };
                step_lines.push(format!(
                    "{n}. `{}` — you do this yourself on your own (cloud) model{cond}.{consumes_note} \
Use the `wren` CLI to write and run the SQL.{produces_note}",
                    call.name
                ));
            }
        }

        let frontmatter = AgentFrontmatter {
            name: node.verb.clone(),
            description: format!(
                "{} (hybrid: local step(s) via bash-script script, cloud step(s) on {})",
                build_description(node),
                driver_model
            ),
            tools: vec!["Read".to_string(), "Bash".to_string()],
            model: Some(driver_model.clone()),
        };
        let body = [
            format!(
                "You are bound to the wren project at `{}`. All DATA access goes through the `wren` \
CLI (never raw SQL clients).",
                node.context_binding.project
            ),
            String::new(),
            context.prompt_section(),
            String::new(),
            "This component runs HYBRID: one or more steps run on a LOCAL model via an emitted script \
(run it through Bash and use its stdout); the rest you do yourself. Follow the steps IN ORDER, \
marshaling each step's output into the next exactly as noted."
                .to_string(),
            String::new(),
            "Steps, in order:".to_string(),
            String::new(),
            step_lines.join("\n"),
            String::new(),
            "Before you answer you MUST verify: actually execute the SQL through `wren`, then validate \
the result set (non-empty where a value is expected, types/units sane, grain matches). If it cannot \
be validated, REFUSE — do not fabricate. Your FINAL message MUST be a single JSON object \
`{\"columns\":[...],\"rows\":[[...]]}` with the answer (numbers as numbers), and nothing else."
                .to_string(),
        ]
        .join("\n");
        let markdown = format!("---\n{}\n---\n\n{}\n", to_yaml(&frontmatter), body);
        fs::write(agents_dir.join(format!("{}.md", node.verb)), markdown)
            .map_err(|e| DispatchError(format!("write agent md: {e}")))?;
    }

    // Settings: read-only data access + the local-inference scripts. NOTE (guardrail trade-off): the
    // bash-script realization must allow `bash` so the driver can run the emitted local-infer wrapper —
    // a wider trusted-command surface than the all-cloud path. An MCP-tool realization would avoid this
    // (the tool is a separate gate, not the Bash allowlist); see capability-model.md §7.2.
    let component_settings = serde_json::json!({
        "$comment": "Hybrid (bash-script) file target: DATA read-only via wren strict_mode; `bash` is \
    allowed ONLY to run the emitted local-inference scripts (a wider surface than all-cloud — an MCP \
    realization would not need it).",
        "permissions": {
            "allow": ["Read", "Bash(wren:*)", "Bash(bash:*)"],
            "deny": DESTRUCTIVE_BASH_DENY_PATTERNS
        }
    });
    let settings = ir
        .components
        .iter()
        .map(|node| (node.verb.clone(), component_settings.clone()))
        .collect::<Vec<_>>();
    write_json(
        &claude_dir.join("settings.json"),
        &merge_scope_settings(&settings),
    )?;
    fs::write(
        wren_dir.join("config.json"),
        format!(
            "{}\n",
            serde_json::to_string_pretty(&wren_config()).expect("wren config serialize")
        ),
    )
    .map_err(|e| DispatchError(format!("write wren config: {e}")))?;
    write_json(
        &out_dir.join("context-report.json"),
        &serde_json::to_value(context.report()).expect("context report serializes"),
    )?;
    let _ = target_id;
    Ok(())
}

/// Emit the hybrid (mcp-server) realization: a `.mcp.json` registering `warble mcp-serve` (stdio) +
/// an `mcp-steps.json` (local step → endpoint/model/system) + a driver that calls the `local_infer`
/// MCP tool for LOCAL steps and does the CLOUD steps itself via `wren`. Cleaner than bash-script: the
/// local call is an MCP tool (its own permission gate), so the read-only agent needs NO `bash`.
pub(super) fn emit_hybrid_file_target_mcp(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    models: &ModelConfig,
    context: &ContextInjection,
) -> Result<(), DispatchError> {
    let claude_dir = out_dir.join(".claude");
    let agents_dir = claude_dir.join("agents");
    mkdir_all(&agents_dir)?;
    mkdir_all(&out_dir.join(".wren"))?;

    // The warble binary that will serve MCP (the one running now); absolute so `claude` can spawn it.
    let warble_bin = std::env::current_exe()
        .ok()
        .and_then(|p| p.to_str().map(str::to_string))
        .unwrap_or_else(|| "warble".to_string());

    let mut mcp_steps = serde_json::Map::new();
    let mut step_lines: Vec<String> = Vec::new();
    for node in &ir.components {
        if !realization_supported(node.realization_kind) {
            return Err(unsupported(
                "realization_kind",
                node.realization_kind.as_str(),
            ));
        }
        if !trigger_supported(node.trigger.kind) {
            return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
        }
        if !outcome_supported(node.effect.outcome.kind) {
            return Err(unsupported(
                "outcome.kind",
                node.effect.outcome.kind.as_str(),
            ));
        }
        if !node.effect.render_blocks.is_empty()
            && find_guardrail(&node.guardrails, ARTIFACT_WRITE_GUARDRAIL_NAME).is_some()
        {
            return Err(DispatchError(format!(
                "hybrid mcp-server file target does not yet realize a render gate for '{}' \
(wall-hit); POC covers render-none components like answer_query",
                node.verb
            )));
        }

        let cloud_calls: Vec<LlmCall> = node
            .llm_calls
            .iter()
            .filter(|c| {
                models
                    .binding(&c.tier)
                    .map(|b| b.provider == ANTHROPIC_PROVIDER)
                    .unwrap_or(false)
            })
            .cloned()
            .collect();
        if cloud_calls.is_empty() {
            return Err(DispatchError(format!(
                "hybrid file target needs at least one cloud (Anthropic) step to host the driver, \
but every step of '{}' is bound to a local provider",
                node.verb
            )));
        }
        let driver_model = models.collapsed_model(&cloud_calls)?.to_string();

        for (i, call) in node.llm_calls.iter().enumerate() {
            let n = i + 1;
            let binding = models.binding(&call.tier)?;
            let consumes_note = if call.consumes.is_empty() {
                String::new()
            } else {
                format!(
                    " It needs {} from the earlier step(s).",
                    call.consumes.join(", ")
                )
            };
            let produces_note = call
                .produces
                .as_ref()
                .map(|p| format!(" Use its returned text as `{p}`."))
                .unwrap_or_default();
            if binding.provider != ANTHROPIC_PROVIDER {
                let endpoint = binding.endpoint.as_deref().ok_or_else(|| {
                    DispatchError(format!("local step '{}' has no endpoint", call.name))
                })?;
                mcp_steps.insert(
                    call.name.clone(),
                    serde_json::json!({
                        "endpoint": endpoint,
                        "model": binding.model,
                        "system": format!("{}\n\n{}", context.prompt_section(), call.prompt),
                    }),
                );
                step_lines.push(format!(
                    "{n}. `{}` — runs on a LOCAL model.{consumes_note} Call the `local_infer` MCP tool \
with `step`=\"{}\" and `input` set to the marshaled input text.{produces_note}",
                    call.name, call.name
                ));
            } else {
                let cond = if call.conditional {
                    " (only if the previous step's result failed validation and needs repair)"
                } else {
                    ""
                };
                step_lines.push(format!(
                    "{n}. `{}` — you do this yourself on your own (cloud) model{cond}.{consumes_note} \
Use the `wren` CLI to write and run the SQL.{produces_note}",
                    call.name
                ));
            }
        }

        let frontmatter = AgentFrontmatter {
            name: node.verb.clone(),
            description: format!(
                "{} (hybrid: local step(s) via the local_infer MCP tool, cloud step(s) on {})",
                build_description(node),
                driver_model
            ),
            tools: vec![
                "Read".to_string(),
                "Bash(wren:*)".to_string(),
                "mcp__warble__local_infer".to_string(),
            ],
            model: Some(driver_model.clone()),
        };
        let body = [
            format!(
                "You are bound to the wren project at `{}`. All DATA access goes through the `wren` \
CLI (never raw SQL clients).",
                node.context_binding.project
            ),
            String::new(),
            context.prompt_section(),
            String::new(),
            "This component runs HYBRID: one or more steps run on a LOCAL model, which you reach by \
calling the `local_infer` MCP tool; the rest you do yourself with `wren`. Follow the steps IN ORDER, \
marshaling each step's output into the next exactly as noted."
                .to_string(),
            String::new(),
            "Steps, in order:".to_string(),
            String::new(),
            step_lines.join("\n"),
            String::new(),
            "Before you answer you MUST verify: actually execute the SQL through `wren`, then validate \
the result set (non-empty where a value is expected, types/units sane, grain matches). If it cannot \
be validated, REFUSE — do not fabricate. Your FINAL message MUST be a single JSON object \
`{\"columns\":[...],\"rows\":[[...]]}` with the answer (numbers as numbers), and nothing else."
                .to_string(),
        ]
        .join("\n");
        let markdown = format!("---\n{}\n---\n\n{}\n", to_yaml(&frontmatter), body);
        fs::write(agents_dir.join(format!("{}.md", node.verb)), markdown)
            .map_err(|e| DispatchError(format!("write agent md: {e}")))?;
    }

    // mcp-steps.json (binding stays here, not in the driver prompt) + its absolute path for .mcp.json.
    let steps_doc = serde_json::json!({ "steps": serde_json::Value::Object(mcp_steps) });
    fs::write(
        out_dir.join("mcp-steps.json"),
        format!(
            "{}\n",
            serde_json::to_string_pretty(&steps_doc).expect("steps serialize")
        ),
    )
    .map_err(|e| DispatchError(format!("write mcp-steps.json: {e}")))?;
    let steps_abs = fs::canonicalize(out_dir.join("mcp-steps.json"))
        .map_err(|e| DispatchError(format!("canonicalize mcp-steps.json: {e}")))?;

    let mcp_config = serde_json::json!({
        "mcpServers": {
            "warble": {
                "command": warble_bin,
                "args": ["mcp-serve", "--steps", steps_abs.to_string_lossy()]
            }
        }
    });
    fs::write(
        out_dir.join(".mcp.json"),
        format!(
            "{}\n",
            serde_json::to_string_pretty(&mcp_config).expect("mcp config serialize")
        ),
    )
    .map_err(|e| DispatchError(format!("write .mcp.json: {e}")))?;

    // Read-only DATA access + the MCP tool. NOTE: unlike bash-script, NO `bash` widening — the local
    // call is the `mcp__warble__local_infer` tool, gated separately from the Bash allowlist
    // (see docs/spec/capability-model.md §7.2).
    let component_settings = serde_json::json!({
        "$comment": "Hybrid (mcp-server) file target: DATA read-only via wren strict_mode; the LOCAL \
    step is the mcp__warble__local_infer tool (a separate gate — no bash widening).",
        "permissions": {
            "allow": ["Read", "Bash(wren:*)", "mcp__warble__local_infer"],
            "deny": DESTRUCTIVE_BASH_DENY_PATTERNS
        }
    });
    let settings = ir
        .components
        .iter()
        .map(|node| (node.verb.clone(), component_settings.clone()))
        .collect::<Vec<_>>();
    write_json(
        &claude_dir.join("settings.json"),
        &merge_scope_settings(&settings),
    )?;
    fs::write(
        out_dir.join(".wren").join("config.json"),
        format!(
            "{}\n",
            serde_json::to_string_pretty(&wren_config()).expect("wren config serialize")
        ),
    )
    .map_err(|e| DispatchError(format!("write wren config: {e}")))?;
    write_json(
        &out_dir.join("context-report.json"),
        &serde_json::to_value(context.report()).expect("context report serializes"),
    )?;
    let _ = target_id;
    Ok(())
}