Skip to main content

warble_claude_code/emit/
mod.rs

1//! claude-code target — emits Claude Code agent runtime files from a resolved Warble IR.
2//!
3//! Dispatch is keyed on IR enum values (`realization_kind`, `trigger.kind`, `effect.outcome.kind`),
4//! never on a component's id/verb. Enum values not yet supported by this target fail loudly
5//! ("wall-hit") rather than silently emitting something wrong — see [`support::unsupported`].
6//!
7//! This module is split by responsibility: [`types`] (public flavor/realization enums), [`support`]
8//! (constants + enum-support predicates + guardrail helpers), [`gate`] (render gate + tool grants),
9//! [`sections`] (agent-body prompt sections), [`agent`] (single-agent markdown), [`settings`]
10//! (per-component `settings.json` + wren config), [`scope`] (the scope-level session envelope and
11//! system prompt), [`run_md`] (RUN.md), [`split`] (per-step-tier split realization), [`isolate`]
12//! (context isolation), [`resolution`] (capability resolution + summary), [`fs_util`] (file
13//! writers), and [`hybrid`] (hybrid local+cloud realization). The public `emit_claude_code*` entry
14//! points live here.
15//!
16//! Everything this module writes is asserted byte-for-byte against a committed snapshot by
17//! `tests/dispatch_snapshot_tests.rs`. That is deliberate: what dispatch emits *is* what an agent
18//! reads, so a change here changes behavior, and the snapshot makes it impossible to land one
19//! without a reviewer seeing exactly what changed.
20
21mod agent;
22mod fs_util;
23mod gate;
24mod hybrid;
25mod isolate;
26mod resolution;
27mod run_md;
28mod scope;
29mod sections;
30mod settings;
31mod split;
32mod support;
33mod types;
34
35pub use resolution::resolve_node_capabilities;
36pub use types::{
37    ContextInjection, ContextInjectionMode, ContextInjectionReport, HybridRealization,
38    RenderFlavor, DEFAULT_CONTEXT_INJECTION, DEFAULT_RENDER_FLAVOR,
39};
40
41use crate::error::DispatchError;
42use crate::interactive::{
43    native_analysis_prompt_fragment, native_analysis_terminal_presentation_instructions,
44    native_answer_persistence_instructions, native_context_enrichment_prompt_fragment,
45    native_context_enrichment_terminal_presentation_instructions,
46    native_dashboard_save_instructions, setup_bootstrap_authority_instructions,
47    setup_recovery_instructions, NativeMcpDescriptor, NativePurpose, NativeSessionScope,
48};
49use crate::ir::{
50    reject_unsupported_component_composition, validate_ir_version, RealizationKind, WarbleIr,
51};
52use crate::models::{ModelConfig, ANTHROPIC_PROVIDER};
53use crate::provider::{compose_target, ProviderFragment, ToolMap};
54use crate::resolve::ResolutionReport;
55use crate::slots::assert_no_slot_references;
56use crate::targets::{CapabilityOutcome, TargetId};
57use std::path::Path;
58
59use agent::build_agent_markdown;
60use fs_util::{mkdir_all, write_file, write_json};
61use hybrid::{
62    any_local_provider, build_hybrid_disclosure, build_hybrid_run_md, emit_hybrid_file_target,
63    emit_hybrid_file_target_mcp,
64};
65use isolate::{
66    build_isolated_child_markdown, build_isolating_parent_markdown, isolated_agent_name,
67    should_isolate,
68};
69use resolution::{print_resolution_summary, resolve_node_with_shared_binding};
70use run_md::{build_interactive_run_md, build_profile_run_md};
71use scope::{build_scope_prompt, merge_scope_settings, scope_denies_destructive_bash};
72use settings::{build_settings, wren_config};
73use split::{
74    build_driver_markdown, build_split_settings, build_subagent_markdown,
75    should_split_per_step_tier, subagent_name,
76};
77use support::{outcome_supported, realization_supported, trigger_supported, unsupported};
78
79fn is_native_dashboard_component(node: &crate::ir::ComponentNode) -> bool {
80    node.required_capabilities
81        .iter()
82        .any(|capability| capability == "genbi_build")
83        && node
84            .required_capabilities
85            .iter()
86            .any(|capability| capability == "artifact_write")
87}
88
89/// The host persistence contract is intentionally derived from the existing
90/// render contract, never from a component id or a second IR representation.
91fn is_native_persisted_answer_component(node: &crate::ir::ComponentNode) -> bool {
92    let blocks = &node.effect.render_blocks;
93    let table = blocks
94        .iter()
95        .filter(|block| block.block_type == "table")
96        .count();
97    let definition = blocks
98        .iter()
99        .filter(|block| block.block_type == "definition")
100        .count();
101    table == 1
102        && definition <= 1
103        && blocks.len() == table + definition
104        && blocks
105            .iter()
106            .find(|block| block.block_type == "table")
107            .is_some_and(|block| {
108                block
109                    .fields
110                    .get("columns")
111                    .is_some_and(|field| field == "string[]")
112                    && block
113                        .fields
114                        .get("rows")
115                        .is_some_and(|field| field == "row[]")
116            })
117        && blocks
118            .iter()
119            .filter(|block| block.block_type == "definition")
120            .all(|block| {
121                block
122                    .fields
123                    .get("sql")
124                    .is_some_and(|field| field == "string")
125                    && block
126                        .fields
127                        .get("source_tables")
128                        .is_some_and(|field| field == "string[]")
129                    && block
130                        .fields
131                        .get("filters")
132                        .is_some_and(|field| field == "string[]")
133            })
134}
135
136fn native_setup_settings(
137    mut settings: serde_json::Value,
138    purpose: Option<NativePurpose>,
139    native_scope: Option<&NativeSessionScope>,
140) -> Result<serde_json::Value, DispatchError> {
141    if purpose != Some(NativePurpose::Setup) {
142        return Ok(settings);
143    }
144    let scope = native_scope.ok_or_else(|| {
145        DispatchError("Setup permissions require a server-derived native scope".to_string())
146    })?;
147    let scoped_permissions = scope.claude_setup_write_permissions()?;
148    let allow = settings
149        .pointer_mut("/permissions/allow")
150        .and_then(serde_json::Value::as_array_mut)
151        .ok_or_else(|| DispatchError("native Setup permissions are incompatible".to_string()))?;
152    allow.retain(|entry| !matches!(entry.as_str(), Some("Edit" | "Write")));
153    for permission in scoped_permissions {
154        allow.push(serde_json::Value::String(permission));
155    }
156    let note = "Setup write authority is server-sealed: only Edit/Write paths below the host-provided WARBLE_SETUP_BOOTSTRAP_ROOT are allowed; the native cwd is private and read-only.";
157    match settings.get_mut("$comment") {
158        Some(comment) => {
159            let existing = comment.as_str().ok_or_else(|| {
160                DispatchError("native Setup permissions are incompatible".to_string())
161            })?;
162            *comment = serde_json::Value::String(format!("{existing} {note}"));
163        }
164        None => {
165            settings["$comment"] = serde_json::Value::String(note.to_string());
166        }
167    }
168    Ok(settings)
169}
170
171/// Refuse a component whose prompt text still carries a slot reference.
172///
173/// The field list mirrors what the CLI's resolution pass rewrites. A prompt-carrying field added to
174/// `ComponentNode` later and missed in both places fails here rather than reaching an agent file.
175fn assert_node_has_no_slot_references(
176    node: &crate::ir::ComponentNode,
177) -> Result<(), DispatchError> {
178    if let Some(brief) = node.brief.as_deref() {
179        assert_no_slot_references(brief, &format!("the brief of component '{}'", node.id))?;
180    }
181    assert_no_slot_references(
182        &node.prompt_fragment,
183        &format!("the prompt fragment of component '{}'", node.id),
184    )?;
185    for call in &node.llm_calls {
186        assert_no_slot_references(
187            &call.prompt,
188            &format!("step '{}' of component '{}'", call.name, node.id),
189        )?;
190    }
191    Ok(())
192}
193
194pub fn emit_claude_code(
195    ir: &WarbleIr,
196    out_dir: &Path,
197    target_id: &str,
198    render_flavor: RenderFlavor,
199) -> Result<(), DispatchError> {
200    emit_claude_code_with_models(
201        ir,
202        out_dir,
203        target_id,
204        render_flavor,
205        &ModelConfig::default(),
206    )
207}
208
209/// Loud-fail if the binding routes any step to a non-Anthropic provider but the target's profile does
210/// not realize `llm:per_step_provider` (hybrid). Anthropic-provider bindings (incl. the shorthand
211/// string form, whose provider defaults to Anthropic) always pass — a local model name that reaches a
212/// whole-session proxy is the caller's choice, not per-step provider routing.
213fn require_per_step_provider_support(
214    ir: &WarbleIr,
215    target_id: &str,
216    models: &ModelConfig,
217) -> Result<(), DispatchError> {
218    let supported = TargetId::parse(target_id)
219        .map(|t| t.profile())
220        .and_then(|p| p.get("llm:per_step_provider").map(|e| e.outcome))
221        .is_some_and(|outcome| outcome != CapabilityOutcome::Fail);
222    if supported {
223        return Ok(());
224    }
225    for node in &ir.components {
226        for call in &node.llm_calls {
227            let binding = models.binding(&call.tier)?;
228            if binding.provider != ANTHROPIC_PROVIDER {
229                return Err(DispatchError(format!(
230                    "llm:per_step_provider: fail on {target_id} — the binding routes step '{}.{}' \
231(tier '{}') to provider '{}', but this target is whole-session single-provider and does not support \
232per-step provider routing (hybrid). Use an all-cloud binding for this target, or dispatch to a target \
233that realizes llm:per_step_provider.",
234                    node.verb,
235                    call.name,
236                    call.tier,
237                    binding.provider.as_str()
238                )));
239            }
240        }
241    }
242    Ok(())
243}
244
245/// Emit Claude Code agent runtime files for a resolved IR into `out_dir`. Errors on any unsupported
246/// enum value rather than emitting a silently-wrong file. Runs the capability resolution pass first;
247/// on abort it errors and writes nothing. `models` resolves each step's tier to a concrete model.
248/// A hybrid binding uses the default [`HybridRealization::BashScript`]; call
249/// [`emit_claude_code_with_realization`] to choose.
250pub fn emit_claude_code_with_models(
251    ir: &WarbleIr,
252    out_dir: &Path,
253    target_id: &str,
254    render_flavor: RenderFlavor,
255    models: &ModelConfig,
256) -> Result<(), DispatchError> {
257    emit_claude_code_with_realization(
258        ir,
259        out_dir,
260        target_id,
261        render_flavor,
262        models,
263        HybridRealization::default(),
264    )
265}
266
267/// As [`emit_claude_code_with_models`], choosing how a hybrid binding's LOCAL step is realized on the
268/// file target (bash-script script vs an MCP server). Only affects the hybrid path.
269pub fn emit_claude_code_with_realization(
270    ir: &WarbleIr,
271    out_dir: &Path,
272    target_id: &str,
273    render_flavor: RenderFlavor,
274    models: &ModelConfig,
275    hybrid: HybridRealization,
276) -> Result<(), DispatchError> {
277    let context = ContextInjection::from_ir(ir, DEFAULT_CONTEXT_INJECTION);
278    emit_claude_code_with_context(
279        ir,
280        out_dir,
281        target_id,
282        render_flavor,
283        models,
284        hybrid,
285        &context,
286    )
287}
288
289/// As [`emit_claude_code_with_realization`], with an explicit host-normalized context payload.
290/// This is the seam a host uses to supply that payload itself; the dispatcher performs no project
291/// I/O of its own, and no CLI flag selects what the payload contains.
292#[allow(clippy::too_many_arguments)]
293pub fn emit_claude_code_with_context(
294    ir: &WarbleIr,
295    out_dir: &Path,
296    target_id: &str,
297    render_flavor: RenderFlavor,
298    models: &ModelConfig,
299    hybrid: HybridRealization,
300    context: &ContextInjection,
301) -> Result<(), DispatchError> {
302    emit_claude_code_with_providers(
303        ir,
304        out_dir,
305        target_id,
306        render_flavor,
307        models,
308        hybrid,
309        context,
310        &[],
311    )
312}
313
314/// As [`emit_claude_code_with_context`], composing the base target with caller-supplied provider
315/// fragments. This is how a domain capability — one naming an external service rather than the
316/// runtime's own structure — reaches this back-end without being hardcoded in it.
317#[allow(clippy::too_many_arguments)]
318pub fn emit_claude_code_with_providers(
319    ir: &WarbleIr,
320    out_dir: &Path,
321    target_id: &str,
322    render_flavor: RenderFlavor,
323    models: &ModelConfig,
324    hybrid: HybridRealization,
325    context: &ContextInjection,
326    providers: &[ProviderFragment],
327) -> Result<(), DispatchError> {
328    emit_claude_code_with_native_purpose(
329        ir,
330        out_dir,
331        target_id,
332        render_flavor,
333        models,
334        hybrid,
335        context,
336        providers,
337        None,
338        None,
339        None,
340    )
341}
342
343/// As [`emit_claude_code_with_providers`], opting into the v2 native Sessions contract.
344/// `purpose` is a closed, server-selected allowlist; it selects neither a caller cwd nor prompt.
345#[allow(clippy::too_many_arguments)]
346pub fn emit_claude_code_with_native_purpose(
347    ir: &WarbleIr,
348    out_dir: &Path,
349    target_id: &str,
350    render_flavor: RenderFlavor,
351    models: &ModelConfig,
352    hybrid: HybridRealization,
353    context: &ContextInjection,
354    providers: &[ProviderFragment],
355    purpose: Option<NativePurpose>,
356    native_scope: Option<NativeSessionScope>,
357    native_mcp: Option<NativeMcpDescriptor>,
358) -> Result<(), DispatchError> {
359    emit_claude_code_with_native_host(
360        ir,
361        out_dir,
362        target_id,
363        render_flavor,
364        models,
365        hybrid,
366        context,
367        providers,
368        purpose,
369        native_scope,
370        native_mcp,
371        None,
372    )
373}
374
375/// Explicit native host execution; old entry points still reject all component calls.
376#[allow(clippy::too_many_arguments)]
377pub fn emit_claude_code_with_native_host(
378    ir: &WarbleIr,
379    out_dir: &Path,
380    target_id: &str,
381    render_flavor: RenderFlavor,
382    models: &ModelConfig,
383    hybrid: HybridRealization,
384    context: &ContextInjection,
385    providers: &[ProviderFragment],
386    purpose: Option<NativePurpose>,
387    native_scope: Option<NativeSessionScope>,
388    native_mcp: Option<NativeMcpDescriptor>,
389    native_host: Option<crate::native_host::NativeHost>,
390) -> Result<(), DispatchError> {
391    validate_ir_version(ir)?;
392    if let Some(host) = &native_host {
393        host.validate(
394            ir,
395            target_id,
396            purpose,
397            native_scope.as_ref(),
398            native_mcp.is_some(),
399        )?;
400    }
401    let mut entry_ir = WarbleIr {
402        components: ir
403            .components
404            .iter()
405            .filter(|node| node.entrypoint)
406            .cloned()
407            .collect(),
408        ..ir.clone()
409    };
410    // Validate the declared verb against all entries before projection can hide ambiguity.
411    if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
412        purpose.validate_profile(&entry_ir, &scope.entry)?;
413    }
414    if let Some(verb) = native_scope
415        .as_ref()
416        .and_then(|scope| scope.entry.pinned_verb())
417    {
418        entry_ir.components.retain(|node| node.id == verb);
419    }
420    let ir = &entry_ir;
421    if native_host.is_none() {
422        reject_unsupported_component_composition(ir, target_id)?;
423    }
424    if let Some(purpose) = purpose {
425        if target_id != "claude-code:interactive" {
426            return Err(DispatchError(
427                "--purpose is supported only by native interactive targets".to_string(),
428            ));
429        }
430        let scope = native_scope.as_ref().ok_or_else(|| {
431            DispatchError(
432                "native Sessions purpose requires a server-derived native scope descriptor"
433                    .to_string(),
434            )
435        })?;
436        purpose.validate_profile(ir, &scope.entry)?;
437    }
438    if native_mcp.is_some() && purpose.is_none() {
439        return Err(DispatchError(
440            "--native-mcp requires a native Sessions --purpose".to_string(),
441        ));
442    }
443    let include_setup_recovery_instructions =
444        purpose == Some(NativePurpose::Setup) && native_mcp.is_some();
445    if target_id == "codex:interactive" {
446        return Err(DispatchError("codex:interactive must use the native Codex materializer, never the Claude file emitter".to_string()));
447    }
448    // This target has no enforceable implementation of the enrichment contract's deterministic
449    // apply capability. Keep generic interactive mutations intact, but exclude that capability;
450    // an apply-only IR wall-hits before it can create a handoff.
451    let filtered_ir;
452    let ir = if target_id == "claude-code:interactive" {
453        filtered_ir = WarbleIr {
454            components: ir
455                .components
456                .iter()
457                .filter(|node| {
458                    !node
459                        .required_capabilities
460                        .iter()
461                        .any(|capability| capability == "enrichment_apply:deterministic")
462                })
463                .cloned()
464                .collect(),
465            ..ir.clone()
466        };
467        if filtered_ir.components.is_empty() {
468            return Err(DispatchError("apply_enrichment cannot be dispatched on claude-code:interactive: native materialization has no enforceable human-approval apply channel (wall-hit)".to_string()));
469        }
470        &filtered_ir
471    } else {
472        ir
473    };
474    // The slot guard. The `warble` CLI resolves slots before this back-end ever sees the document,
475    // so in the normal case there is nothing here to find. What this catches is a LIBRARY caller
476    // that built or loaded an IR itself and skipped that pass — without it, an unresolved
477    // `{{ slot.… }}` would be written straight into a `.claude/agents/*.md` file, which is the same
478    // silent failure this whole mechanism exists to close, just one layer out.
479    for node in &ir.components {
480        assert_node_has_no_slot_references(node)?;
481    }
482    // Every step tier must map to a model — abort before writing anything if one is undefined.
483    models.validate(ir)?;
484    // Both shapes that emit a delegating parent — the per-step split and context isolation — need
485    // the reserved `orchestrator` tier for it. Required up front so emission cannot fail halfway
486    // through writing files.
487    if ir
488        .components
489        .iter()
490        .any(|n| should_split_per_step_tier(n) || should_isolate(n))
491    {
492        models.orchestrator()?;
493    }
494    // Binding-time hybrid gate (llm:per_step_provider). Whether hybrid is needed is a property of the
495    // runtime BINDING (a step bound to a non-Anthropic provider), not the IR — so it is checked here,
496    // once the models config is known, rather than as an IR-static required capability. If the target's
497    // profile does not realize `llm:per_step_provider`, a non-Anthropic binding is a loud-fail (never a
498    // silent emit of a model name that would depend on a whole-session proxy).
499    require_per_step_provider_support(ir, target_id, models)?;
500
501    // Validate the target's enum surface before any all-cloud output is written.
502    for node in &ir.components {
503        if !realization_supported(node.realization_kind) {
504            return Err(unsupported(
505                "realization_kind",
506                node.realization_kind.as_str(),
507            ));
508        }
509        if !trigger_supported(node.trigger.kind) {
510            return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
511        }
512        if !outcome_supported(node.effect.outcome.kind) {
513            return Err(unsupported(
514                "outcome.kind",
515                node.effect.outcome.kind.as_str(),
516            ));
517        }
518    }
519
520    // Compose the base target with any provider fragments BEFORE resolving: a capability a
521    // fragment supplies must be visible to the resolution pass, or every domain capability would
522    // resolve as unknown and abort. Composition is also where a malformed or colliding fragment
523    // loud-fails, which must happen before anything is written.
524    let target = TargetId::parse(target_id).ok_or_else(|| {
525        DispatchError(format!(
526            "target '{target_id}' has no capability profile (known targets: {})",
527            crate::targets::known_target_names().join(", ")
528        ))
529    })?;
530    let composed = compose_target(target.profile(), ToolMap::new(), providers, target)?;
531    let tool_map = &composed.tool_map;
532
533    // Resolve every node first — abort before writing anything if any capability fails.
534    let mut reports: Vec<(String, ResolutionReport)> = Vec::with_capacity(ir.components.len());
535    for node in &ir.components {
536        // A `gated-tool` with divergent step tiers is a wall-hit, not a split: the split
537        // realization's subagents receive the same mutation-guardrail tool grants
538        // (Edit/Write/Bash(warble:*)) as a single unsplit mutating agent (see
539        // `split::build_subagent_markdown` -> `gate::build_tools`), because that tool-building
540        // path has no notion of "this is one step of a gated component, not the whole thing." For
541        // `gated-tool` specifically, only the driver's prompt carries the two-phase approval
542        // sequence (`sections::build_mutation_section`) — so splitting would hand every subagent
543        // independent, ungated write authority over the same guarded target, duplicating write
544        // access outside the driver's dry-run -> blast-radius -> human-approval -> apply lifecycle.
545        // That is exactly the "moves, duplicates, or bypasses the approval gate" case the per-step-
546        // tier contract requires us to refuse rather than force, so this fails loudly here — before
547        // any file is written — instead of either silently collapsing the tiers (the original bug)
548        // or unsafely splitting write authority across subagents (what naively removing the
549        // realization-kind guard from `should_split_per_step_tier` would do). `tool` and `skill`
550        // realizations have no such approval boundary to protect and always split; only
551        // `gated-tool` is refused.
552        if node.realization_kind == RealizationKind::GatedTool && should_split_per_step_tier(node) {
553            return Err(DispatchError(format!(
554                "llm:per_step_tier: gated-tool component '{}' 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) — 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`.",
555                node.verb
556            )));
557        }
558        if native_host
559            .as_ref()
560            .is_some_and(|host| host.tool(&node.id).is_some())
561        {
562            reports.push((node.id.clone(), crate::native_host::resolution_report(node)));
563            continue;
564        }
565        reports.push((
566            node.id.clone(),
567            resolve_node_with_shared_binding(
568                node,
569                &ir.context_binding,
570                target_id,
571                &composed.profile,
572            )?,
573        ));
574    }
575    let report_for = |id: &str| -> &ResolutionReport {
576        &reports
577            .iter()
578            .find(|(nid, _)| nid == id)
579            .expect("report exists")
580            .1
581    };
582
583    // Provider routing is a realization detail, not an exemption from the scope contract. Resolve
584    // first so hybrid output can reuse the same profile inventory and resolved limits as all-cloud
585    // output, then emit one scope prompt, one RUN.md, and one merged session envelope.
586    if any_local_provider(ir, models)? {
587        if native_mcp.is_some() {
588            return Err(DispatchError(
589                "native MCP discovery requires an all-cloud native interactive materialization; hybrid MCP configuration is a separate producer contract"
590                    .to_string(),
591            ));
592        }
593        let scope_components = ir
594            .components
595            .iter()
596            .map(|node| (node, report_for(&node.id)))
597            .collect::<Vec<_>>();
598        let disclosure = build_hybrid_disclosure(ir, models, hybrid)?;
599        let scope_prompt = format!(
600            "{}{}",
601            build_scope_prompt(ir, &scope_components, render_flavor, true),
602            disclosure
603        );
604        let run = build_hybrid_run_md(ir, target_id, &disclosure);
605        match hybrid {
606            HybridRealization::BashScript => {
607                emit_hybrid_file_target(ir, out_dir, target_id, models, context)?
608            }
609            HybridRealization::McpServer => {
610                emit_hybrid_file_target_mcp(ir, out_dir, target_id, models, context)?
611            }
612        }
613        write_file(&out_dir.join(".claude/CLAUDE.md"), &scope_prompt)?;
614        write_file(&out_dir.join("RUN.md"), &run)?;
615        return Ok(());
616    }
617
618    // Interactive output is launched in the emitted directory. Preflight its user-visible
619    // handoff/spec before any write so a hostile pre-existing file cannot be overwritten.
620    let interactive = if target_id == "claude-code:interactive" {
621        let signature = ir
622            .components
623            .iter()
624            .map(|node| node.id.as_str())
625            .collect::<Vec<_>>()
626            .join(",");
627        let mut paths = vec![
628            std::path::PathBuf::from("RUN.md"),
629            std::path::PathBuf::from("context-report.json"),
630            std::path::PathBuf::from("capability-report.json"),
631            std::path::PathBuf::from(".claude/CLAUDE.md"),
632            std::path::PathBuf::from(".claude/settings.json"),
633            std::path::PathBuf::from(".wren/config.json"),
634        ];
635        if native_mcp.is_some() {
636            paths.push(std::path::PathBuf::from(".mcp.json"));
637        }
638        if native_host.is_some() {
639            paths.push(std::path::PathBuf::from(".warble/component-plans.json"));
640        }
641        for node in &ir.components {
642            paths.push(std::path::PathBuf::from(format!(
643                ".claude/agents/{}.md",
644                node.verb
645            )));
646            if should_split_per_step_tier(node)
647                && native_host
648                    .as_ref()
649                    .is_none_or(|host| host.tool(&node.id).is_none())
650            {
651                for call in &node.llm_calls {
652                    paths.push(std::path::PathBuf::from(format!(
653                        ".claude/agents/{}.md",
654                        subagent_name(&node.verb, call)
655                    )));
656                }
657            }
658        }
659        Some(crate::interactive::prepare_interactive_output_with_host(
660            out_dir,
661            target_id,
662            "claude",
663            &signature,
664            &ir.profile,
665            &paths,
666            purpose,
667            native_scope.clone(),
668            native_mcp.clone(),
669            native_host.clone(),
670        )?)
671    } else {
672        None
673    };
674    let out_dir = interactive
675        .as_ref()
676        .map_or(out_dir, |output| output.root.as_path());
677
678    // Write only after the all-cloud resolution pass succeeds, preserving the back-end's
679    // abort-before-write contract. Hybrid emitters write the same report after their own gates.
680    mkdir_all(out_dir)?;
681    write_json(
682        &out_dir.join("context-report.json"),
683        &serde_json::to_value(context.report()).expect("context report serializes"),
684    )?;
685    // `.claude/settings.json` is session-scoped: one file the runtime loads once, whichever agent
686    // the session selects. So the native MCP allowlist is decided for the profile rather than by
687    // whichever component happens to hold the dashboard shape.
688    let include_session_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
689        && native_mcp.is_some()
690        && ir.components.iter().any(|node| {
691            is_native_dashboard_component(node) || is_native_persisted_answer_component(node)
692        });
693    let include_session_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
694        && native_mcp.is_some()
695        && ir
696            .components
697            .iter()
698            .any(is_native_persisted_answer_component);
699
700    let claude_dir = out_dir.join(".claude");
701    let agents_dir = claude_dir.join("agents");
702    let wren_dir = out_dir.join(".wren");
703    mkdir_all(&agents_dir)?;
704    mkdir_all(&wren_dir)?;
705    // Each component's own envelope, merged into the session's after the loop. Collected rather
706    // than written: a component-scoped write to a session-scoped path is last-writer-wins, not a
707    // stricter grant.
708    let mut component_settings: Vec<(String, serde_json::Value)> =
709        Vec::with_capacity(ir.components.len());
710
711    for node in &ir.components {
712        let include_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
713            && native_mcp.is_some()
714            && (is_native_dashboard_component(node) || is_native_persisted_answer_component(node));
715        let include_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
716            && native_mcp.is_some()
717            && is_native_persisted_answer_component(node);
718        let include_native_terminal_presentation = matches!(
719            purpose,
720            Some(NativePurpose::Analysis | NativePurpose::ContextEnrichment)
721        );
722        // Native analysis and context enrichment share source IR with programmatic dispatch, but
723        // their final terminal response is Markdown rather than the IR's JSON transport. Rewrite
724        // only this emitted native view; the IR and every non-native target retain the exact JSON
725        // contract.
726        let mut rendered_node;
727        let node = if include_native_terminal_presentation {
728            rendered_node = node.clone();
729            rendered_node.prompt_fragment = match purpose {
730                Some(NativePurpose::Analysis) => {
731                    native_analysis_prompt_fragment(&node.prompt_fragment)
732                }
733                Some(NativePurpose::ContextEnrichment) => {
734                    native_context_enrichment_prompt_fragment(&node.prompt_fragment)
735                }
736                _ => unreachable!("native terminal presentation requires a native purpose"),
737            };
738            &rendered_node
739        } else {
740            node
741        };
742        let report = report_for(&node.id);
743
744        // Isolation is checked before the per-step split because the two want opposite things from
745        // the same component: the split hands each STEP its own child and marshals artifacts through
746        // the parent, which is precisely the leakage isolation exists to stop. When both apply the
747        // component asked for the boundary, so it wins and the tiers collapse — visibly, in the
748        // child's own tier comment and in capability-report.json.
749        if let Some(host) = native_host
750            .as_ref()
751            .filter(|host| host.tool(&node.id).is_some())
752        {
753            write_file(
754                &agents_dir.join(format!("{}.md", node.verb)),
755                &host.claude_wrapper(node),
756            )?;
757            // Preserve the existing top-level scope union envelope. The wrapper's own tools
758            // contain only its fixed root admission tool; governed steps never inherit this union.
759            let mut settings = if should_split_per_step_tier(node) || should_isolate(node) {
760                build_split_settings(
761                    node,
762                    report,
763                    render_flavor,
764                    tool_map,
765                    include_session_dashboard_save_tool,
766                    include_session_persist_answer_tool,
767                )
768            } else {
769                build_settings(
770                    node,
771                    report,
772                    render_flavor,
773                    tool_map,
774                    false,
775                    include_session_dashboard_save_tool,
776                    include_session_persist_answer_tool,
777                )
778            };
779            settings["permissions"]["allow"]
780                .as_array_mut()
781                .expect("settings allow")
782                .push(serde_json::json!(format!(
783                    "mcp__genbi_session__{}",
784                    host.tool(&node.id).expect("root")
785                )));
786            component_settings.push((node.verb.clone(), settings));
787        } else if should_isolate(node) {
788            write_file(
789                &agents_dir.join(format!("{}.md", node.verb)),
790                &build_isolating_parent_markdown(node, report, render_flavor, models)?,
791            )?;
792            write_file(
793                &agents_dir.join(format!("{}.md", isolated_agent_name(&node.verb))),
794                &build_isolated_child_markdown(
795                    node,
796                    report,
797                    render_flavor,
798                    models,
799                    context,
800                    tool_map,
801                    include_setup_recovery_instructions,
802                    include_dashboard_save_tool,
803                )?,
804            )?;
805            // The parent needs Task/Read and the child needs the component's own tools, which is
806            // exactly the union the split path already computes.
807            component_settings.push((
808                node.verb.clone(),
809                build_split_settings(
810                    node,
811                    report,
812                    render_flavor,
813                    tool_map,
814                    include_session_dashboard_save_tool,
815                    include_session_persist_answer_tool,
816                ),
817            ));
818        } else if should_split_per_step_tier(node) {
819            let mut driver = build_driver_markdown(
820                node,
821                report,
822                render_flavor,
823                models,
824                context,
825                include_dashboard_save_tool,
826                include_persist_answer_tool,
827                include_native_terminal_presentation,
828            );
829            if include_persist_answer_tool {
830                driver.push('\n');
831                driver.push_str(native_answer_persistence_instructions());
832            }
833            if include_dashboard_save_tool {
834                driver.push('\n');
835                driver.push_str(native_dashboard_save_instructions());
836            }
837            if include_native_terminal_presentation {
838                driver.push('\n');
839                driver.push_str(match purpose {
840                    Some(NativePurpose::Analysis) => {
841                        native_analysis_terminal_presentation_instructions()
842                    }
843                    Some(NativePurpose::ContextEnrichment) => {
844                        native_context_enrichment_terminal_presentation_instructions()
845                    }
846                    _ => unreachable!("native terminal presentation requires a native purpose"),
847                });
848            }
849            if purpose == Some(NativePurpose::Setup) {
850                driver.push('\n');
851                driver.push_str(&setup_bootstrap_authority_instructions());
852            }
853            write_file(&agents_dir.join(format!("{}.md", node.verb)), &driver)?;
854            for call in &node.llm_calls {
855                let mut subagent = build_subagent_markdown(node, call, models, context, tool_map);
856                if purpose == Some(NativePurpose::Setup) {
857                    subagent.push('\n');
858                    subagent.push_str(&setup_bootstrap_authority_instructions());
859                }
860                write_file(
861                    &agents_dir.join(format!("{}.md", subagent_name(&node.verb, call))),
862                    &subagent,
863                )?;
864            }
865            component_settings.push((
866                node.verb.clone(),
867                build_split_settings(
868                    node,
869                    report,
870                    render_flavor,
871                    tool_map,
872                    include_session_dashboard_save_tool,
873                    include_session_persist_answer_tool,
874                ),
875            ));
876        } else {
877            let mut agent_markdown = build_agent_markdown(
878                node,
879                report,
880                render_flavor,
881                models,
882                context,
883                tool_map,
884                include_setup_recovery_instructions,
885                include_dashboard_save_tool,
886            )?;
887            if include_dashboard_save_tool {
888                agent_markdown.push('\n');
889                agent_markdown.push_str(native_dashboard_save_instructions());
890            }
891            if include_native_terminal_presentation {
892                agent_markdown.push('\n');
893                agent_markdown.push_str(match purpose {
894                    Some(NativePurpose::Analysis) => {
895                        native_analysis_terminal_presentation_instructions()
896                    }
897                    Some(NativePurpose::ContextEnrichment) => {
898                        native_context_enrichment_terminal_presentation_instructions()
899                    }
900                    _ => unreachable!("native terminal presentation requires a native purpose"),
901                });
902            }
903            if include_setup_recovery_instructions {
904                agent_markdown.push('\n');
905                agent_markdown.push_str(setup_recovery_instructions());
906            }
907            if purpose == Some(NativePurpose::Setup) {
908                agent_markdown.push('\n');
909                agent_markdown.push_str(&setup_bootstrap_authority_instructions());
910            }
911            write_file(
912                &agents_dir.join(format!("{}.md", node.verb)),
913                &agent_markdown,
914            )?;
915            component_settings.push((
916                node.verb.clone(),
917                build_settings(
918                    node,
919                    report,
920                    render_flavor,
921                    tool_map,
922                    include_setup_recovery_instructions,
923                    include_session_dashboard_save_tool,
924                    include_session_persist_answer_tool,
925                ),
926            ));
927        }
928    }
929
930    // Scope-level artifacts, computed once for the profile. The runtime loads
931    // one settings file, one data-layer config and one project memory per session — emitting them
932    // per component writes N times to each path and lets whichever component the IR ends with
933    // decide the session's envelope.
934    let scope_components = ir
935        .components
936        .iter()
937        .map(|node| (node, report_for(&node.id)))
938        .collect::<Vec<_>>();
939    write_json(
940        &claude_dir.join("settings.json"),
941        &native_setup_settings(
942            merge_scope_settings(&component_settings),
943            purpose,
944            native_scope.as_ref(),
945        )?,
946    )?;
947    write_json(&wren_dir.join("config.json"), &wren_config())?;
948    let scope_prompt = build_scope_prompt(
949        ir,
950        &scope_components,
951        render_flavor,
952        scope_denies_destructive_bash(&component_settings),
953    );
954    write_file(
955        &claude_dir.join("CLAUDE.md"),
956        &match interactive.as_ref() {
957            Some(output) => format!("{}\n{scope_prompt}", output.marker()),
958            None => scope_prompt,
959        },
960    )?;
961
962    // RUN.md is profile-level: one emitted directory, one document. Writing it inside the loop
963    // above would write it once per component to the same path, leaving the last component the only
964    // one documented and the rest of the profile invisible.
965    let run = match interactive.as_ref() {
966        Some(output) => format!(
967            "{}\n{}",
968            output.marker(),
969            build_interactive_run_md(
970                ir,
971                purpose,
972                native_scope
973                    .as_ref()
974                    .and_then(|scope| scope.entry.pinned_verb())
975            )
976        ),
977        None => build_profile_run_md(&ir.profile, &scope_components, render_flavor, models)?,
978    };
979    write_file(&out_dir.join("RUN.md"), &run)?;
980
981    if let (Some(_), Some(descriptor)) = (&interactive, native_mcp.as_ref()) {
982        write_file(
983            &out_dir.join(".mcp.json"),
984            &descriptor.claude_discovery_config()?,
985        )?;
986        // The interactive output remains the sole writer of the ownership manifest and launch
987        // spec, after this exact vendor-owned discovery file is present and can be hashed.
988    }
989
990    let capability_report = serde_json::json!({
991        "target": target_id,
992        "components": ir.components.iter().map(|node| {
993            let mut entry = serde_json::json!({
994                "id": node.id,
995                "capabilities": report_for(&node.id),
996            });
997            // Which provider-supplied tools this component was actually granted, and where each is
998            // realized. Without it, a reader of the emitted agent sees tool names with no way to
999            // tell which fragment put them there or what backs them.
1000            let granted: serde_json::Map<String, serde_json::Value> = node
1001                .required_capabilities
1002                .iter()
1003                .filter_map(|c| tool_map.get(c.as_str()).map(|b| (c, b)))
1004                .map(|(capability, binding)| {
1005                    (
1006                        capability.clone(),
1007                        serde_json::json!({ "names": binding.names, "source": binding.source }),
1008                    )
1009                })
1010                .collect();
1011            if !granted.is_empty() {
1012                entry["tool_bindings"] = serde_json::Value::Object(granted);
1013            }
1014            // Isolation swallows the per-step-tier split, and swallowing a realized capability is a
1015            // degrade — so it is recorded rather than left for a reader to infer from the absence of
1016            // subagent files ("no silent caps", capability-model §4).
1017            if should_isolate(node) {
1018                let collapsed = models.collapsed_model(&node.llm_calls).ok();
1019                entry["isolation"] = serde_json::json!({
1020                    "realized_via": "single-child-subagent",
1021                    "child_agent": isolated_agent_name(&node.verb),
1022                    "per_step_tier": if should_split_per_step_tier(node) {
1023                        serde_json::json!({
1024                            "outcome": "degrade",
1025                            "reason": "the whole component runs in one child, so its steps share one model",
1026                            "collapsed_to": collapsed,
1027                        })
1028                    } else {
1029                        serde_json::json!({ "outcome": "not-requested" })
1030                    },
1031                });
1032            }
1033            entry
1034        }).collect::<Vec<_>>(),
1035    });
1036    write_json(&out_dir.join("capability-report.json"), &capability_report)?;
1037
1038    for node in &ir.components {
1039        print_resolution_summary(
1040            &format!("{target_id} (component '{}')", node.id),
1041            report_for(&node.id),
1042        );
1043    }
1044
1045    if let Some(output) = interactive {
1046        output.write_ownership()?;
1047        output.write_launch_spec()?;
1048    }
1049    Ok(())
1050}