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