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 context.absent() != ir.context_binding.absent {
393        return Err(DispatchError::new(
394            "context injection must agree with IR context absence",
395        ));
396    }
397    if ir.context_binding.absent {
398        if !matches!(
399            target_id,
400            "claude-code:headless" | "claude-code:interactive"
401        ) || purpose.is_some()
402            || native_scope.is_some()
403            || native_mcp.is_some()
404            || native_host.is_some()
405            || !providers.is_empty()
406        {
407            return Err(DispatchError::new("context-free IR requires a plain native Claude Code file target without provider/native-session bindings"));
408        }
409        for node in &ir.components {
410            if let Some(param) = node.params.iter().find(|param| param.source.is_some()) {
411                return Err(DispatchError::new(format!(
412                    "component '{}': runtime-injected parameter '{}' is not supported by context-free native file target '{target_id}'",
413                    node.id, param.name
414                )));
415            }
416            let simple = node.component_type == crate::ir::ComponentType::Analytical
417                && node.realization_kind == crate::ir::RealizationKind::Skill
418                && node.trigger.kind == crate::ir::TriggerKind::OneShot
419                && node.effect.outcome.kind == crate::ir::OutcomeKind::None
420                && node.effect.render_blocks.is_empty()
421                && node.context_requirements.is_empty()
422                && node.context_precondition.is_empty()
423                && node.precondition_result.checks.is_empty()
424                && node.borrowed_actions.is_empty()
425                && node.guardrails.len() == 1
426                && node.guardrails[0].name == "read_only_execution"
427                && node.guardrails[0].locked
428                && node.guardrails[0].scope.is_none()
429                && node.guardrails[0].threshold.is_none()
430                && node.llm_calls.len() == 1
431                && !node.llm_calls[0].conditional
432                && node.llm_calls[0].when.is_none()
433                && node.llm_calls[0].component_calls.is_empty()
434                && node.llm_calls[0].consumes.is_empty()
435                && node
436                    .required_capabilities
437                    .iter()
438                    .all(|c| c.starts_with("llm:"));
439            if !simple {
440                return Err(DispatchError::new(format!("component '{}': context-free execution on '{target_id}' supports only a single-step read-only analytical skill with LLM capabilities and no data requirements or effects", node.id)));
441            }
442            if models.binding(&node.llm_calls[0].tier)?.provider
443                != crate::models::ANTHROPIC_PROVIDER
444            {
445                return Err(DispatchError::new(
446                    "context-free CLI skills do not support hybrid providers",
447                ));
448            }
449        }
450    }
451    if let Some(host) = &native_host {
452        host.validate(
453            ir,
454            target_id,
455            purpose,
456            native_scope.as_ref(),
457            native_mcp.is_some(),
458        )?;
459    }
460    let mut entry_ir = WarbleIr {
461        components: ir
462            .components
463            .iter()
464            .filter(|node| node.entrypoint)
465            .cloned()
466            .collect(),
467        ..ir.clone()
468    };
469    // Validate the declared verb against all entries before projection can hide ambiguity.
470    if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
471        purpose.validate_profile(&entry_ir, &scope.entry)?;
472    }
473    if let Some(verb) = native_scope
474        .as_ref()
475        .and_then(|scope| scope.entry.pinned_verb())
476    {
477        entry_ir.components.retain(|node| node.id == verb);
478    }
479    let ir = &entry_ir;
480    if native_host.is_none() {
481        reject_unsupported_component_composition(ir, target_id)?;
482    }
483    if let Some(purpose) = purpose {
484        if target_id != "claude-code:interactive" {
485            return Err(DispatchError(
486                "--purpose is supported only by native interactive targets".to_string(),
487            ));
488        }
489        let scope = native_scope.as_ref().ok_or_else(|| {
490            DispatchError(
491                "native Sessions purpose requires a server-derived native scope descriptor"
492                    .to_string(),
493            )
494        })?;
495        purpose.validate_profile(ir, &scope.entry)?;
496    }
497    if native_mcp.is_some() && purpose.is_none() {
498        return Err(DispatchError(
499            "--native-mcp requires a native Sessions --purpose".to_string(),
500        ));
501    }
502    let include_setup_recovery_instructions =
503        purpose == Some(NativePurpose::Setup) && native_mcp.is_some();
504    if target_id == "codex:interactive" {
505        return Err(DispatchError("codex:interactive must use the native Codex materializer, never the Claude file emitter".to_string()));
506    }
507    // This target has no enforceable implementation of the enrichment contract's deterministic
508    // apply capability. Keep generic interactive mutations intact, but exclude that capability;
509    // an apply-only IR wall-hits before it can create a handoff.
510    let filtered_ir;
511    let ir = if target_id == "claude-code:interactive" {
512        filtered_ir = WarbleIr {
513            components: ir
514                .components
515                .iter()
516                .filter(|node| {
517                    !node
518                        .required_capabilities
519                        .iter()
520                        .any(|capability| capability == "enrichment_apply:deterministic")
521                })
522                .cloned()
523                .collect(),
524            ..ir.clone()
525        };
526        if filtered_ir.components.is_empty() {
527            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()));
528        }
529        &filtered_ir
530    } else {
531        ir
532    };
533    // The slot guard. The `warble` CLI resolves slots before this back-end ever sees the document,
534    // so in the normal case there is nothing here to find. What this catches is a LIBRARY caller
535    // that built or loaded an IR itself and skipped that pass — without it, an unresolved
536    // `{{ slot.… }}` would be written straight into a `.claude/agents/*.md` file, which is the same
537    // silent failure this whole mechanism exists to close, just one layer out.
538    for node in &ir.components {
539        assert_node_has_no_slot_references(node)?;
540    }
541    // Every step tier must map to a model — abort before writing anything if one is undefined.
542    models.validate(ir)?;
543    // Both shapes that emit a delegating parent — the per-step split and context isolation — need
544    // the reserved `orchestrator` tier for it. Required up front so emission cannot fail halfway
545    // through writing files.
546    if ir
547        .components
548        .iter()
549        .any(|n| should_split_per_step_tier(n) || should_isolate(n))
550    {
551        models.orchestrator()?;
552    }
553    // Binding-time hybrid gate (llm:per_step_provider). Whether hybrid is needed is a property of the
554    // runtime BINDING (a step bound to a non-Anthropic provider), not the IR — so it is checked here,
555    // once the models config is known, rather than as an IR-static required capability. If the target's
556    // profile does not realize `llm:per_step_provider`, a non-Anthropic binding is a loud-fail (never a
557    // silent emit of a model name that would depend on a whole-session proxy).
558    require_per_step_provider_support(ir, target_id, models)?;
559
560    // Validate the target's enum surface before any all-cloud output is written.
561    for node in &ir.components {
562        if !realization_supported(node.realization_kind) {
563            return Err(unsupported(
564                "realization_kind",
565                node.realization_kind.as_str(),
566            ));
567        }
568        if !trigger_supported(node.trigger.kind) {
569            return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
570        }
571        if !outcome_supported(node.effect.outcome.kind) {
572            return Err(unsupported(
573                "outcome.kind",
574                node.effect.outcome.kind.as_str(),
575            ));
576        }
577    }
578
579    // Compose the base target with any provider fragments BEFORE resolving: a capability a
580    // fragment supplies must be visible to the resolution pass, or every domain capability would
581    // resolve as unknown and abort. Composition is also where a malformed or colliding fragment
582    // loud-fails, which must happen before anything is written.
583    let target = TargetId::parse(target_id).ok_or_else(|| {
584        DispatchError(format!(
585            "target '{target_id}' has no capability profile (known targets: {})",
586            crate::targets::known_target_names().join(", ")
587        ))
588    })?;
589    let composed = compose_target(target.profile(), ToolMap::new(), providers, target)?;
590    let tool_map = &composed.tool_map;
591
592    // Resolve every node first — abort before writing anything if any capability fails.
593    let mut reports: Vec<(String, ResolutionReport)> = Vec::with_capacity(ir.components.len());
594    for node in &ir.components {
595        // A `gated-tool` with divergent step tiers is a wall-hit, not a split: the split
596        // realization's subagents receive the same mutation-guardrail tool grants
597        // (Edit/Write/Bash(warble:*)) as a single unsplit mutating agent (see
598        // `split::build_subagent_markdown` -> `gate::build_tools`), because that tool-building
599        // path has no notion of "this is one step of a gated component, not the whole thing." For
600        // `gated-tool` specifically, only the driver's prompt carries the two-phase approval
601        // sequence (`sections::build_mutation_section`) — so splitting would hand every subagent
602        // independent, ungated write authority over the same guarded target, duplicating write
603        // access outside the driver's dry-run -> blast-radius -> human-approval -> apply lifecycle.
604        // That is exactly the "moves, duplicates, or bypasses the approval gate" case the per-step-
605        // tier contract requires us to refuse rather than force, so this fails loudly here — before
606        // any file is written — instead of either silently collapsing the tiers (the original bug)
607        // or unsafely splitting write authority across subagents (what naively removing the
608        // realization-kind guard from `should_split_per_step_tier` would do). `tool` and `skill`
609        // realizations have no such approval boundary to protect and always split; only
610        // `gated-tool` is refused.
611        if node.realization_kind == RealizationKind::GatedTool && should_split_per_step_tier(node) {
612            return Err(DispatchError(format!(
613                "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`.",
614                node.verb
615            )));
616        }
617        if native_host
618            .as_ref()
619            .is_some_and(|host| host.tool(&node.id).is_some())
620        {
621            reports.push((node.id.clone(), crate::native_host::resolution_report(node)));
622            continue;
623        }
624        reports.push((
625            node.id.clone(),
626            resolve_node_with_shared_binding(
627                node,
628                &ir.context_binding,
629                target_id,
630                &composed.profile,
631            )?,
632        ));
633    }
634    let report_for = |id: &str| -> &ResolutionReport {
635        &reports
636            .iter()
637            .find(|(nid, _)| nid == id)
638            .expect("report exists")
639            .1
640    };
641
642    // Provider routing is a realization detail, not an exemption from the scope contract. Resolve
643    // first so hybrid output can reuse the same profile inventory and resolved limits as all-cloud
644    // output, then emit one scope prompt, one RUN.md, and one merged session envelope.
645    if any_local_provider(ir, models)? {
646        if native_mcp.is_some() {
647            return Err(DispatchError(
648                "native MCP discovery requires an all-cloud native interactive materialization; hybrid MCP configuration is a separate producer contract"
649                    .to_string(),
650            ));
651        }
652        let scope_components = ir
653            .components
654            .iter()
655            .map(|node| (node, report_for(&node.id)))
656            .collect::<Vec<_>>();
657        let disclosure = build_hybrid_disclosure(ir, models, hybrid)?;
658        let scope_prompt = format!(
659            "{}{}",
660            build_scope_prompt(ir, &scope_components, render_flavor, true),
661            disclosure
662        );
663        let run = build_hybrid_run_md(ir, target_id, &disclosure);
664        match hybrid {
665            HybridRealization::BashScript => {
666                emit_hybrid_file_target(ir, out_dir, target_id, models, context)?
667            }
668            HybridRealization::McpServer => {
669                emit_hybrid_file_target_mcp(ir, out_dir, target_id, models, context)?
670            }
671        }
672        write_file(&out_dir.join(".claude/CLAUDE.md"), &scope_prompt)?;
673        write_file(&out_dir.join("RUN.md"), &run)?;
674        return Ok(());
675    }
676
677    // Interactive output is launched in the emitted directory. Preflight its user-visible
678    // handoff/spec before any write so a hostile pre-existing file cannot be overwritten.
679    let interactive = if target_id == "claude-code:interactive" {
680        let signature = ir
681            .components
682            .iter()
683            .map(|node| node.id.as_str())
684            .collect::<Vec<_>>()
685            .join(",");
686        let mut paths = vec![
687            std::path::PathBuf::from("RUN.md"),
688            std::path::PathBuf::from("context-report.json"),
689            std::path::PathBuf::from("capability-report.json"),
690            std::path::PathBuf::from(".claude/CLAUDE.md"),
691            std::path::PathBuf::from(".claude/settings.json"),
692            std::path::PathBuf::from(".wren/config.json"),
693        ];
694        if ir.context_binding.absent {
695            paths.retain(|path| path != std::path::Path::new(".wren/config.json"));
696        }
697        if native_mcp.is_some() {
698            paths.push(std::path::PathBuf::from(".mcp.json"));
699        }
700        if native_host.is_some() {
701            paths.push(std::path::PathBuf::from(".warble/component-plans.json"));
702        }
703        for node in &ir.components {
704            paths.push(std::path::PathBuf::from(format!(
705                ".claude/agents/{}.md",
706                node.verb
707            )));
708            if should_split_per_step_tier(node)
709                && native_host
710                    .as_ref()
711                    .is_none_or(|host| host.tool(&node.id).is_none())
712            {
713                for call in &node.llm_calls {
714                    paths.push(std::path::PathBuf::from(format!(
715                        ".claude/agents/{}.md",
716                        subagent_name(&node.verb, call)
717                    )));
718                }
719            }
720        }
721        Some(crate::interactive::prepare_interactive_output_with_host(
722            out_dir,
723            target_id,
724            "claude",
725            &signature,
726            &ir.profile,
727            &paths,
728            purpose,
729            native_scope.clone(),
730            native_mcp.clone(),
731            native_host.clone(),
732            None,
733        )?)
734    } else {
735        None
736    };
737    let out_dir = interactive
738        .as_ref()
739        .map_or(out_dir, |output| output.root.as_path());
740
741    // Write only after the all-cloud resolution pass succeeds, preserving the back-end's
742    // abort-before-write contract. Hybrid emitters write the same report after their own gates.
743    mkdir_all(out_dir)?;
744    write_json(
745        &out_dir.join("context-report.json"),
746        &serde_json::to_value(context.report()).expect("context report serializes"),
747    )?;
748    // `.claude/settings.json` is session-scoped: one file the runtime loads once, whichever agent
749    // the session selects. So the native MCP allowlist is decided for the profile rather than by
750    // whichever component happens to hold the dashboard shape.
751    let include_session_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
752        && native_mcp.is_some()
753        && ir.components.iter().any(|node| {
754            is_native_dashboard_component(node) || is_native_persisted_answer_component(node)
755        });
756    let include_session_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
757        && native_mcp.is_some()
758        && ir
759            .components
760            .iter()
761            .any(is_native_persisted_answer_component);
762
763    let claude_dir = out_dir.join(".claude");
764    let agents_dir = claude_dir.join("agents");
765    let wren_dir = out_dir.join(".wren");
766    mkdir_all(&agents_dir)?;
767    if !ir.context_binding.absent {
768        mkdir_all(&wren_dir)?;
769    }
770    // Each component's own envelope, merged into the session's after the loop. Collected rather
771    // than written: a component-scoped write to a session-scoped path is last-writer-wins, not a
772    // stricter grant.
773    let mut component_settings: Vec<(String, serde_json::Value)> =
774        Vec::with_capacity(ir.components.len());
775
776    for node in &ir.components {
777        let include_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
778            && native_mcp.is_some()
779            && (is_native_dashboard_component(node) || is_native_persisted_answer_component(node));
780        let include_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
781            && native_mcp.is_some()
782            && is_native_persisted_answer_component(node);
783        let include_native_terminal_presentation = matches!(
784            purpose,
785            Some(NativePurpose::Analysis | NativePurpose::ContextEnrichment)
786        );
787        // Native analysis and context enrichment share source IR with programmatic dispatch, but
788        // their final terminal response is Markdown rather than the IR's JSON transport. Rewrite
789        // only this emitted native view; the IR and every non-native target retain the exact JSON
790        // contract.
791        let mut rendered_node;
792        let node = if include_native_terminal_presentation {
793            rendered_node = node.clone();
794            rendered_node.prompt_fragment = match purpose {
795                Some(NativePurpose::Analysis) => {
796                    native_analysis_prompt_fragment(&node.prompt_fragment)
797                }
798                Some(NativePurpose::ContextEnrichment) => {
799                    native_context_enrichment_prompt_fragment(&node.prompt_fragment)
800                }
801                _ => unreachable!("native terminal presentation requires a native purpose"),
802            };
803            &rendered_node
804        } else {
805            node
806        };
807        let report = report_for(&node.id);
808
809        // Isolation is checked before the per-step split because the two want opposite things from
810        // the same component: the split hands each STEP its own child and marshals artifacts through
811        // the parent, which is precisely the leakage isolation exists to stop. When both apply the
812        // component asked for the boundary, so it wins and the tiers collapse — visibly, in the
813        // child's own tier comment and in capability-report.json.
814        if let Some(host) = native_host
815            .as_ref()
816            .filter(|host| host.tool(&node.id).is_some())
817        {
818            write_file(
819                &agents_dir.join(format!("{}.md", node.verb)),
820                &host.claude_wrapper(node),
821            )?;
822            // Preserve the existing top-level scope union envelope. The wrapper's own tools
823            // contain only its fixed root admission tool; governed steps never inherit this union.
824            let mut settings = if should_split_per_step_tier(node) || should_isolate(node) {
825                build_split_settings(
826                    node,
827                    report,
828                    render_flavor,
829                    tool_map,
830                    include_session_dashboard_save_tool,
831                    include_session_persist_answer_tool,
832                )
833            } else {
834                build_settings(
835                    node,
836                    report,
837                    render_flavor,
838                    tool_map,
839                    false,
840                    include_session_dashboard_save_tool,
841                    include_session_persist_answer_tool,
842                )
843            };
844            settings["permissions"]["allow"]
845                .as_array_mut()
846                .expect("settings allow")
847                .push(serde_json::json!(format!(
848                    "mcp__genbi_session__{}",
849                    host.tool(&node.id).expect("root")
850                )));
851            component_settings.push((node.verb.clone(), settings));
852        } else if should_isolate(node) {
853            write_file(
854                &agents_dir.join(format!("{}.md", node.verb)),
855                &build_isolating_parent_markdown(node, report, render_flavor, models)?,
856            )?;
857            write_file(
858                &agents_dir.join(format!("{}.md", isolated_agent_name(&node.verb))),
859                &build_isolated_child_markdown(
860                    node,
861                    report,
862                    render_flavor,
863                    models,
864                    context,
865                    tool_map,
866                    include_setup_recovery_instructions,
867                    include_dashboard_save_tool,
868                )?,
869            )?;
870            // The parent needs Task/Read and the child needs the component's own tools, which is
871            // exactly the union the split path already computes.
872            component_settings.push((
873                node.verb.clone(),
874                build_split_settings(
875                    node,
876                    report,
877                    render_flavor,
878                    tool_map,
879                    include_session_dashboard_save_tool,
880                    include_session_persist_answer_tool,
881                ),
882            ));
883        } else if should_split_per_step_tier(node) {
884            let mut driver = build_driver_markdown(
885                node,
886                report,
887                render_flavor,
888                models,
889                context,
890                include_dashboard_save_tool,
891                include_persist_answer_tool,
892                include_native_terminal_presentation,
893            );
894            if include_persist_answer_tool {
895                driver.push('\n');
896                driver.push_str(native_answer_persistence_instructions());
897            }
898            if include_dashboard_save_tool {
899                driver.push('\n');
900                driver.push_str(native_dashboard_save_instructions());
901            }
902            if include_native_terminal_presentation {
903                driver.push('\n');
904                driver.push_str(match purpose {
905                    Some(NativePurpose::Analysis) => {
906                        native_analysis_terminal_presentation_instructions()
907                    }
908                    Some(NativePurpose::ContextEnrichment) => {
909                        native_context_enrichment_terminal_presentation_instructions()
910                    }
911                    _ => unreachable!("native terminal presentation requires a native purpose"),
912                });
913            }
914            if purpose == Some(NativePurpose::Setup) {
915                driver.push('\n');
916                driver.push_str(&setup_bootstrap_authority_instructions());
917            }
918            write_file(&agents_dir.join(format!("{}.md", node.verb)), &driver)?;
919            for call in &node.llm_calls {
920                let mut subagent = build_subagent_markdown(node, call, models, context, tool_map);
921                if purpose == Some(NativePurpose::Setup) {
922                    subagent.push('\n');
923                    subagent.push_str(&setup_bootstrap_authority_instructions());
924                }
925                write_file(
926                    &agents_dir.join(format!("{}.md", subagent_name(&node.verb, call))),
927                    &subagent,
928                )?;
929            }
930            component_settings.push((
931                node.verb.clone(),
932                build_split_settings(
933                    node,
934                    report,
935                    render_flavor,
936                    tool_map,
937                    include_session_dashboard_save_tool,
938                    include_session_persist_answer_tool,
939                ),
940            ));
941        } else {
942            let mut agent_markdown = build_agent_markdown(
943                node,
944                report,
945                render_flavor,
946                models,
947                context,
948                tool_map,
949                include_setup_recovery_instructions,
950                include_dashboard_save_tool,
951            )?;
952            if include_dashboard_save_tool {
953                agent_markdown.push('\n');
954                agent_markdown.push_str(native_dashboard_save_instructions());
955            }
956            if include_native_terminal_presentation {
957                agent_markdown.push('\n');
958                agent_markdown.push_str(match purpose {
959                    Some(NativePurpose::Analysis) => {
960                        native_analysis_terminal_presentation_instructions()
961                    }
962                    Some(NativePurpose::ContextEnrichment) => {
963                        native_context_enrichment_terminal_presentation_instructions()
964                    }
965                    _ => unreachable!("native terminal presentation requires a native purpose"),
966                });
967            }
968            if include_setup_recovery_instructions {
969                agent_markdown.push('\n');
970                agent_markdown.push_str(setup_recovery_instructions());
971            }
972            if purpose == Some(NativePurpose::Setup) {
973                agent_markdown.push('\n');
974                agent_markdown.push_str(&setup_bootstrap_authority_instructions());
975            }
976            write_file(
977                &agents_dir.join(format!("{}.md", node.verb)),
978                &agent_markdown,
979            )?;
980            component_settings.push((
981                node.verb.clone(),
982                build_settings(
983                    node,
984                    report,
985                    render_flavor,
986                    tool_map,
987                    include_setup_recovery_instructions,
988                    include_session_dashboard_save_tool,
989                    include_session_persist_answer_tool,
990                ),
991            ));
992        }
993    }
994
995    // Scope-level artifacts, computed once for the profile. The runtime loads
996    // one settings file, one data-layer config and one project memory per session — emitting them
997    // per component writes N times to each path and lets whichever component the IR ends with
998    // decide the session's envelope.
999    let scope_components = ir
1000        .components
1001        .iter()
1002        .map(|node| (node, report_for(&node.id)))
1003        .collect::<Vec<_>>();
1004    write_json(
1005        &claude_dir.join("settings.json"),
1006        &native_setup_settings(
1007            merge_scope_settings(&component_settings, !ir.context_binding.absent),
1008            purpose,
1009            native_scope.as_ref(),
1010        )?,
1011    )?;
1012    if !ir.context_binding.absent {
1013        write_json(&wren_dir.join("config.json"), &wren_config())?;
1014    }
1015    let scope_prompt = build_scope_prompt(
1016        ir,
1017        &scope_components,
1018        render_flavor,
1019        scope_denies_destructive_bash(&component_settings),
1020    );
1021    write_file(
1022        &claude_dir.join("CLAUDE.md"),
1023        &match interactive.as_ref() {
1024            Some(output) => format!("{}\n{scope_prompt}", output.marker()),
1025            None => scope_prompt,
1026        },
1027    )?;
1028
1029    // RUN.md is profile-level: one emitted directory, one document. Writing it inside the loop
1030    // above would write it once per component to the same path, leaving the last component the only
1031    // one documented and the rest of the profile invisible.
1032    let run = match interactive.as_ref() {
1033        Some(output) => format!(
1034            "{}\n{}",
1035            output.marker(),
1036            build_interactive_run_md(
1037                ir,
1038                purpose,
1039                native_scope
1040                    .as_ref()
1041                    .and_then(|scope| scope.entry.pinned_verb())
1042            )
1043        ),
1044        None => build_profile_run_md(&ir.profile, &scope_components, render_flavor, models)?,
1045    };
1046    write_file(&out_dir.join("RUN.md"), &run)?;
1047
1048    if let (Some(_), Some(descriptor)) = (&interactive, native_mcp.as_ref()) {
1049        write_file(
1050            &out_dir.join(".mcp.json"),
1051            &descriptor.claude_discovery_config()?,
1052        )?;
1053        // The interactive output remains the sole writer of the ownership manifest and launch
1054        // spec, after this exact vendor-owned discovery file is present and can be hashed.
1055    }
1056
1057    let capability_report = serde_json::json!({
1058        "target": target_id,
1059        "components": ir.components.iter().map(|node| {
1060            let mut entry = serde_json::json!({
1061                "id": node.id,
1062                "capabilities": report_for(&node.id),
1063            });
1064            // Which provider-supplied tools this component was actually granted, and where each is
1065            // realized. Without it, a reader of the emitted agent sees tool names with no way to
1066            // tell which fragment put them there or what backs them.
1067            let granted: serde_json::Map<String, serde_json::Value> = node
1068                .required_capabilities
1069                .iter()
1070                .filter_map(|c| tool_map.get(c.as_str()).map(|b| (c, b)))
1071                .map(|(capability, binding)| {
1072                    (
1073                        capability.clone(),
1074                        serde_json::json!({ "names": binding.names, "source": binding.source }),
1075                    )
1076                })
1077                .collect();
1078            if !granted.is_empty() {
1079                entry["tool_bindings"] = serde_json::Value::Object(granted);
1080            }
1081            // Isolation swallows the per-step-tier split, and swallowing a realized capability is a
1082            // degrade — so it is recorded rather than left for a reader to infer from the absence of
1083            // subagent files ("no silent caps", capability-model §4).
1084            if should_isolate(node) {
1085                let collapsed = models.collapsed_model(&node.llm_calls).ok();
1086                entry["isolation"] = serde_json::json!({
1087                    "realized_via": "single-child-subagent",
1088                    "child_agent": isolated_agent_name(&node.verb),
1089                    "per_step_tier": if should_split_per_step_tier(node) {
1090                        serde_json::json!({
1091                            "outcome": "degrade",
1092                            "reason": "the whole component runs in one child, so its steps share one model",
1093                            "collapsed_to": collapsed,
1094                        })
1095                    } else {
1096                        serde_json::json!({ "outcome": "not-requested" })
1097                    },
1098                });
1099            }
1100            entry
1101        }).collect::<Vec<_>>(),
1102    });
1103    write_json(&out_dir.join("capability-report.json"), &capability_report)?;
1104
1105    for node in &ir.components {
1106        print_resolution_summary(
1107            &format!("{target_id} (component '{}')", node.id),
1108            report_for(&node.id),
1109        );
1110    }
1111
1112    if let Some(output) = interactive {
1113        output.write_ownership()?;
1114        output.write_launch_spec()?;
1115    }
1116    Ok(())
1117}