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