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