warble-claude-code 0.15.1

Warble back-end for the Claude Code CLI target: IR → static agent files + reference renderer + capability manifest.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
//! claude-code target — emits Claude Code agent runtime files from a resolved Warble IR.
//!
//! Dispatch is keyed on IR enum values (`realization_kind`, `trigger.kind`, `effect.outcome.kind`),
//! never on a component's id/verb. Enum values not yet supported by this target fail loudly
//! ("wall-hit") rather than silently emitting something wrong — see [`support::unsupported`].
//!
//! This module is split by responsibility: [`types`] (public flavor/realization enums), [`support`]
//! (constants + enum-support predicates + guardrail helpers), [`gate`] (render gate + tool grants),
//! [`sections`] (agent-body prompt sections), [`agent`] (single-agent markdown), [`settings`]
//! (per-component `settings.json` + wren config), [`scope`] (the scope-level session envelope and
//! system prompt), [`run_md`] (RUN.md), [`split`] (per-step-tier split realization), [`isolate`]
//! (context isolation), [`resolution`] (capability resolution + summary), [`fs_util`] (file
//! writers), and [`hybrid`] (hybrid local+cloud realization). The public `emit_claude_code*` entry
//! points live here.
//!
//! Everything this module writes is asserted byte-for-byte against a committed snapshot by
//! `tests/dispatch_snapshot_tests.rs`. That is deliberate: what dispatch emits *is* what an agent
//! reads, so a change here changes behavior, and the snapshot makes it impossible to land one
//! without a reviewer seeing exactly what changed.

mod agent;
mod fs_util;
mod gate;
mod hybrid;
mod isolate;
mod resolution;
mod run_md;
mod scope;
mod sections;
mod settings;
mod split;
mod support;
mod types;

pub use resolution::resolve_node_capabilities;
pub use types::{
    ContextInjection, ContextInjectionMode, ContextInjectionReport, HybridRealization,
    RenderFlavor, DEFAULT_CONTEXT_INJECTION, DEFAULT_RENDER_FLAVOR,
};

use crate::error::DispatchError;
use crate::interactive::{
    native_analysis_prompt_fragment, native_analysis_terminal_presentation_instructions,
    native_answer_persistence_instructions, native_context_enrichment_prompt_fragment,
    native_context_enrichment_terminal_presentation_instructions,
    native_dashboard_save_instructions, setup_bootstrap_authority_instructions,
    setup_recovery_instructions, NativeMcpDescriptor, NativePurpose, NativeSessionScope,
};
use crate::ir::{
    reject_unsupported_component_composition, validate_ir_version, RealizationKind, WarbleIr,
};
use crate::models::{ModelConfig, ANTHROPIC_PROVIDER};
use crate::provider::{compose_target, ProviderFragment, ToolMap};
use crate::resolve::ResolutionReport;
use crate::slots::assert_no_slot_references;
use crate::targets::{CapabilityOutcome, TargetId};
use std::path::Path;

use agent::build_agent_markdown;
use fs_util::{mkdir_all, write_file, write_json};
use hybrid::{
    any_local_provider, build_hybrid_disclosure, build_hybrid_run_md, emit_hybrid_file_target,
    emit_hybrid_file_target_mcp,
};
use isolate::{
    build_isolated_child_markdown, build_isolating_parent_markdown, isolated_agent_name,
    should_isolate,
};
use resolution::{print_resolution_summary, resolve_node_with_shared_binding};
use run_md::{build_interactive_run_md, build_profile_run_md};
use scope::{build_scope_prompt, merge_scope_settings, scope_denies_destructive_bash};
use settings::{build_settings, wren_config};
use split::{
    build_driver_markdown, build_split_settings, build_subagent_markdown,
    should_split_per_step_tier, subagent_name,
};
use support::{outcome_supported, realization_supported, trigger_supported, unsupported};

fn is_native_dashboard_component(node: &crate::ir::ComponentNode) -> bool {
    node.required_capabilities
        .iter()
        .any(|capability| capability == "genbi_build")
        && node
            .required_capabilities
            .iter()
            .any(|capability| capability == "artifact_write")
}

/// The host persistence contract is intentionally derived from the existing
/// render contract, never from a component id or a second IR representation.
fn is_native_persisted_answer_component(node: &crate::ir::ComponentNode) -> bool {
    let blocks = &node.effect.render_blocks;
    let table = blocks
        .iter()
        .filter(|block| block.block_type == "table")
        .count();
    let definition = blocks
        .iter()
        .filter(|block| block.block_type == "definition")
        .count();
    table == 1
        && definition <= 1
        && blocks.len() == table + definition
        && blocks
            .iter()
            .find(|block| block.block_type == "table")
            .is_some_and(|block| {
                block
                    .fields
                    .get("columns")
                    .is_some_and(|field| field == "string[]")
                    && block
                        .fields
                        .get("rows")
                        .is_some_and(|field| field == "row[]")
            })
        && blocks
            .iter()
            .filter(|block| block.block_type == "definition")
            .all(|block| {
                block
                    .fields
                    .get("sql")
                    .is_some_and(|field| field == "string")
                    && block
                        .fields
                        .get("source_tables")
                        .is_some_and(|field| field == "string[]")
                    && block
                        .fields
                        .get("filters")
                        .is_some_and(|field| field == "string[]")
            })
}

fn native_setup_settings(
    mut settings: serde_json::Value,
    purpose: Option<NativePurpose>,
    native_scope: Option<&NativeSessionScope>,
) -> Result<serde_json::Value, DispatchError> {
    if purpose != Some(NativePurpose::Setup) {
        return Ok(settings);
    }
    let scope = native_scope.ok_or_else(|| {
        DispatchError("Setup permissions require a server-derived native scope".to_string())
    })?;
    let scoped_permissions = scope.claude_setup_write_permissions()?;
    let allow = settings
        .pointer_mut("/permissions/allow")
        .and_then(serde_json::Value::as_array_mut)
        .ok_or_else(|| DispatchError("native Setup permissions are incompatible".to_string()))?;
    allow.retain(|entry| !matches!(entry.as_str(), Some("Edit" | "Write")));
    for permission in scoped_permissions {
        allow.push(serde_json::Value::String(permission));
    }
    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.";
    match settings.get_mut("$comment") {
        Some(comment) => {
            let existing = comment.as_str().ok_or_else(|| {
                DispatchError("native Setup permissions are incompatible".to_string())
            })?;
            *comment = serde_json::Value::String(format!("{existing} {note}"));
        }
        None => {
            settings["$comment"] = serde_json::Value::String(note.to_string());
        }
    }
    Ok(settings)
}

/// Refuse a component whose prompt text still carries a slot reference.
///
/// The field list mirrors what the CLI's resolution pass rewrites. A prompt-carrying field added to
/// `ComponentNode` later and missed in both places fails here rather than reaching an agent file.
fn assert_node_has_no_slot_references(
    node: &crate::ir::ComponentNode,
) -> Result<(), DispatchError> {
    if let Some(brief) = node.brief.as_deref() {
        assert_no_slot_references(brief, &format!("the brief of component '{}'", node.id))?;
    }
    assert_no_slot_references(
        &node.prompt_fragment,
        &format!("the prompt fragment of component '{}'", node.id),
    )?;
    for call in &node.llm_calls {
        assert_no_slot_references(
            &call.prompt,
            &format!("step '{}' of component '{}'", call.name, node.id),
        )?;
    }
    Ok(())
}

pub fn emit_claude_code(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    render_flavor: RenderFlavor,
) -> Result<(), DispatchError> {
    emit_claude_code_with_models(
        ir,
        out_dir,
        target_id,
        render_flavor,
        &ModelConfig::default(),
    )
}

/// Loud-fail if the binding routes any step to a non-Anthropic provider but the target's profile does
/// not realize `llm:per_step_provider` (hybrid). Anthropic-provider bindings (incl. the shorthand
/// string form, whose provider defaults to Anthropic) always pass — a local model name that reaches a
/// whole-session proxy is the caller's choice, not per-step provider routing.
fn require_per_step_provider_support(
    ir: &WarbleIr,
    target_id: &str,
    models: &ModelConfig,
) -> Result<(), DispatchError> {
    let supported = TargetId::parse(target_id)
        .map(|t| t.profile())
        .and_then(|p| p.get("llm:per_step_provider").map(|e| e.outcome))
        .is_some_and(|outcome| outcome != CapabilityOutcome::Fail);
    if supported {
        return Ok(());
    }
    for node in &ir.components {
        for call in &node.llm_calls {
            let binding = models.binding(&call.tier)?;
            if binding.provider != ANTHROPIC_PROVIDER {
                return Err(DispatchError(format!(
                    "llm:per_step_provider: fail on {target_id} — the binding routes step '{}.{}' \
(tier '{}') to provider '{}', but this target is whole-session single-provider and does not support \
per-step provider routing (hybrid). Use an all-cloud binding for this target, or dispatch to a target \
that realizes llm:per_step_provider.",
                    node.verb,
                    call.name,
                    call.tier,
                    binding.provider.as_str()
                )));
            }
        }
    }
    Ok(())
}

/// Emit Claude Code agent runtime files for a resolved IR into `out_dir`. Errors on any unsupported
/// enum value rather than emitting a silently-wrong file. Runs the capability resolution pass first;
/// on abort it errors and writes nothing. `models` resolves each step's tier to a concrete model.
/// A hybrid binding uses the default [`HybridRealization::BashScript`]; call
/// [`emit_claude_code_with_realization`] to choose.
pub fn emit_claude_code_with_models(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    render_flavor: RenderFlavor,
    models: &ModelConfig,
) -> Result<(), DispatchError> {
    emit_claude_code_with_realization(
        ir,
        out_dir,
        target_id,
        render_flavor,
        models,
        HybridRealization::default(),
    )
}

/// As [`emit_claude_code_with_models`], choosing how a hybrid binding's LOCAL step is realized on the
/// file target (bash-script script vs an MCP server). Only affects the hybrid path.
pub fn emit_claude_code_with_realization(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    render_flavor: RenderFlavor,
    models: &ModelConfig,
    hybrid: HybridRealization,
) -> Result<(), DispatchError> {
    let context = ContextInjection::from_ir(ir, DEFAULT_CONTEXT_INJECTION);
    emit_claude_code_with_context(
        ir,
        out_dir,
        target_id,
        render_flavor,
        models,
        hybrid,
        &context,
    )
}

/// As [`emit_claude_code_with_realization`], with an explicit host-normalized context payload.
/// This is the seam a host uses to supply that payload itself; the dispatcher performs no project
/// I/O of its own, and no CLI flag selects what the payload contains.
#[allow(clippy::too_many_arguments)]
pub fn emit_claude_code_with_context(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    render_flavor: RenderFlavor,
    models: &ModelConfig,
    hybrid: HybridRealization,
    context: &ContextInjection,
) -> Result<(), DispatchError> {
    emit_claude_code_with_providers(
        ir,
        out_dir,
        target_id,
        render_flavor,
        models,
        hybrid,
        context,
        &[],
    )
}

/// As [`emit_claude_code_with_context`], composing the base target with caller-supplied provider
/// fragments. This is how a domain capability — one naming an external service rather than the
/// runtime's own structure — reaches this back-end without being hardcoded in it.
#[allow(clippy::too_many_arguments)]
pub fn emit_claude_code_with_providers(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    render_flavor: RenderFlavor,
    models: &ModelConfig,
    hybrid: HybridRealization,
    context: &ContextInjection,
    providers: &[ProviderFragment],
) -> Result<(), DispatchError> {
    emit_claude_code_with_native_purpose(
        ir,
        out_dir,
        target_id,
        render_flavor,
        models,
        hybrid,
        context,
        providers,
        None,
        None,
        None,
    )
}

/// As [`emit_claude_code_with_providers`], opting into the v2 native Sessions contract.
/// `purpose` is a closed, server-selected allowlist; it selects neither a caller cwd nor prompt.
#[allow(clippy::too_many_arguments)]
pub fn emit_claude_code_with_native_purpose(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    render_flavor: RenderFlavor,
    models: &ModelConfig,
    hybrid: HybridRealization,
    context: &ContextInjection,
    providers: &[ProviderFragment],
    purpose: Option<NativePurpose>,
    native_scope: Option<NativeSessionScope>,
    native_mcp: Option<NativeMcpDescriptor>,
) -> Result<(), DispatchError> {
    emit_claude_code_with_native_host(
        ir,
        out_dir,
        target_id,
        render_flavor,
        models,
        hybrid,
        context,
        providers,
        purpose,
        native_scope,
        native_mcp,
        None,
    )
}

/// Explicit native host execution; old entry points still reject all component calls.
#[allow(clippy::too_many_arguments)]
pub fn emit_claude_code_with_native_host(
    ir: &WarbleIr,
    out_dir: &Path,
    target_id: &str,
    render_flavor: RenderFlavor,
    models: &ModelConfig,
    hybrid: HybridRealization,
    context: &ContextInjection,
    providers: &[ProviderFragment],
    purpose: Option<NativePurpose>,
    native_scope: Option<NativeSessionScope>,
    native_mcp: Option<NativeMcpDescriptor>,
    native_host: Option<crate::native_host::NativeHost>,
) -> Result<(), DispatchError> {
    validate_ir_version(ir)?;
    if let Some(host) = &native_host {
        host.validate(
            ir,
            target_id,
            purpose,
            native_scope.as_ref(),
            native_mcp.is_some(),
        )?;
    }
    let mut entry_ir = WarbleIr {
        components: ir
            .components
            .iter()
            .filter(|node| node.entrypoint)
            .cloned()
            .collect(),
        ..ir.clone()
    };
    // Validate the declared verb against all entries before projection can hide ambiguity.
    if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
        purpose.validate_profile(&entry_ir, &scope.entry)?;
    }
    if let Some(verb) = native_scope
        .as_ref()
        .and_then(|scope| scope.entry.pinned_verb())
    {
        entry_ir.components.retain(|node| node.id == verb);
    }
    let ir = &entry_ir;
    if native_host.is_none() {
        reject_unsupported_component_composition(ir, target_id)?;
    }
    if let Some(purpose) = purpose {
        if target_id != "claude-code:interactive" {
            return Err(DispatchError(
                "--purpose is supported only by native interactive targets".to_string(),
            ));
        }
        let scope = native_scope.as_ref().ok_or_else(|| {
            DispatchError(
                "native Sessions purpose requires a server-derived native scope descriptor"
                    .to_string(),
            )
        })?;
        purpose.validate_profile(ir, &scope.entry)?;
    }
    if native_mcp.is_some() && purpose.is_none() {
        return Err(DispatchError(
            "--native-mcp requires a native Sessions --purpose".to_string(),
        ));
    }
    let include_setup_recovery_instructions =
        purpose == Some(NativePurpose::Setup) && native_mcp.is_some();
    if target_id == "codex:interactive" {
        return Err(DispatchError("codex:interactive must use the native Codex materializer, never the Claude file emitter".to_string()));
    }
    // This target has no enforceable implementation of the enrichment contract's deterministic
    // apply capability. Keep generic interactive mutations intact, but exclude that capability;
    // an apply-only IR wall-hits before it can create a handoff.
    let filtered_ir;
    let ir = if target_id == "claude-code:interactive" {
        filtered_ir = WarbleIr {
            components: ir
                .components
                .iter()
                .filter(|node| {
                    !node
                        .required_capabilities
                        .iter()
                        .any(|capability| capability == "enrichment_apply:deterministic")
                })
                .cloned()
                .collect(),
            ..ir.clone()
        };
        if filtered_ir.components.is_empty() {
            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()));
        }
        &filtered_ir
    } else {
        ir
    };
    // The slot guard. The `warble` CLI resolves slots before this back-end ever sees the document,
    // so in the normal case there is nothing here to find. What this catches is a LIBRARY caller
    // that built or loaded an IR itself and skipped that pass — without it, an unresolved
    // `{{ slot.… }}` would be written straight into a `.claude/agents/*.md` file, which is the same
    // silent failure this whole mechanism exists to close, just one layer out.
    for node in &ir.components {
        assert_node_has_no_slot_references(node)?;
    }
    // Every step tier must map to a model — abort before writing anything if one is undefined.
    models.validate(ir)?;
    // Both shapes that emit a delegating parent — the per-step split and context isolation — need
    // the reserved `orchestrator` tier for it. Required up front so emission cannot fail halfway
    // through writing files.
    if ir
        .components
        .iter()
        .any(|n| should_split_per_step_tier(n) || should_isolate(n))
    {
        models.orchestrator()?;
    }
    // Binding-time hybrid gate (llm:per_step_provider). Whether hybrid is needed is a property of the
    // runtime BINDING (a step bound to a non-Anthropic provider), not the IR — so it is checked here,
    // once the models config is known, rather than as an IR-static required capability. If the target's
    // profile does not realize `llm:per_step_provider`, a non-Anthropic binding is a loud-fail (never a
    // silent emit of a model name that would depend on a whole-session proxy).
    require_per_step_provider_support(ir, target_id, models)?;

    // Validate the target's enum surface before any all-cloud output is written.
    for node in &ir.components {
        if !realization_supported(node.realization_kind) {
            return Err(unsupported(
                "realization_kind",
                node.realization_kind.as_str(),
            ));
        }
        if !trigger_supported(node.trigger.kind) {
            return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
        }
        if !outcome_supported(node.effect.outcome.kind) {
            return Err(unsupported(
                "outcome.kind",
                node.effect.outcome.kind.as_str(),
            ));
        }
    }

    // Compose the base target with any provider fragments BEFORE resolving: a capability a
    // fragment supplies must be visible to the resolution pass, or every domain capability would
    // resolve as unknown and abort. Composition is also where a malformed or colliding fragment
    // loud-fails, which must happen before anything is written.
    let target = TargetId::parse(target_id).ok_or_else(|| {
        DispatchError(format!(
            "target '{target_id}' has no capability profile (known targets: {})",
            crate::targets::known_target_names().join(", ")
        ))
    })?;
    let composed = compose_target(target.profile(), ToolMap::new(), providers, target)?;
    let tool_map = &composed.tool_map;

    // Resolve every node first — abort before writing anything if any capability fails.
    let mut reports: Vec<(String, ResolutionReport)> = Vec::with_capacity(ir.components.len());
    for node in &ir.components {
        // A `gated-tool` with divergent step tiers is a wall-hit, not a split: the split
        // realization's subagents receive the same mutation-guardrail tool grants
        // (Edit/Write/Bash(warble:*)) as a single unsplit mutating agent (see
        // `split::build_subagent_markdown` -> `gate::build_tools`), because that tool-building
        // path has no notion of "this is one step of a gated component, not the whole thing." For
        // `gated-tool` specifically, only the driver's prompt carries the two-phase approval
        // sequence (`sections::build_mutation_section`) — so splitting would hand every subagent
        // independent, ungated write authority over the same guarded target, duplicating write
        // access outside the driver's dry-run -> blast-radius -> human-approval -> apply lifecycle.
        // That is exactly the "moves, duplicates, or bypasses the approval gate" case the per-step-
        // tier contract requires us to refuse rather than force, so this fails loudly here — before
        // any file is written — instead of either silently collapsing the tiers (the original bug)
        // or unsafely splitting write authority across subagents (what naively removing the
        // realization-kind guard from `should_split_per_step_tier` would do). `tool` and `skill`
        // realizations have no such approval boundary to protect and always split; only
        // `gated-tool` is refused.
        if node.realization_kind == RealizationKind::GatedTool && should_split_per_step_tier(node) {
            return Err(DispatchError(format!(
                "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`.",
                node.verb
            )));
        }
        if native_host
            .as_ref()
            .is_some_and(|host| host.tool(&node.id).is_some())
        {
            reports.push((node.id.clone(), crate::native_host::resolution_report(node)));
            continue;
        }
        reports.push((
            node.id.clone(),
            resolve_node_with_shared_binding(
                node,
                &ir.context_binding,
                target_id,
                &composed.profile,
            )?,
        ));
    }
    let report_for = |id: &str| -> &ResolutionReport {
        &reports
            .iter()
            .find(|(nid, _)| nid == id)
            .expect("report exists")
            .1
    };

    // Provider routing is a realization detail, not an exemption from the scope contract. Resolve
    // first so hybrid output can reuse the same profile inventory and resolved limits as all-cloud
    // output, then emit one scope prompt, one RUN.md, and one merged session envelope.
    if any_local_provider(ir, models)? {
        if native_mcp.is_some() {
            return Err(DispatchError(
                "native MCP discovery requires an all-cloud native interactive materialization; hybrid MCP configuration is a separate producer contract"
                    .to_string(),
            ));
        }
        let scope_components = ir
            .components
            .iter()
            .map(|node| (node, report_for(&node.id)))
            .collect::<Vec<_>>();
        let disclosure = build_hybrid_disclosure(ir, models, hybrid)?;
        let scope_prompt = format!(
            "{}{}",
            build_scope_prompt(ir, &scope_components, render_flavor, true),
            disclosure
        );
        let run = build_hybrid_run_md(ir, target_id, &disclosure);
        match hybrid {
            HybridRealization::BashScript => {
                emit_hybrid_file_target(ir, out_dir, target_id, models, context)?
            }
            HybridRealization::McpServer => {
                emit_hybrid_file_target_mcp(ir, out_dir, target_id, models, context)?
            }
        }
        write_file(&out_dir.join(".claude/CLAUDE.md"), &scope_prompt)?;
        write_file(&out_dir.join("RUN.md"), &run)?;
        return Ok(());
    }

    // Interactive output is launched in the emitted directory. Preflight its user-visible
    // handoff/spec before any write so a hostile pre-existing file cannot be overwritten.
    let interactive = if target_id == "claude-code:interactive" {
        let signature = ir
            .components
            .iter()
            .map(|node| node.id.as_str())
            .collect::<Vec<_>>()
            .join(",");
        let mut paths = vec![
            std::path::PathBuf::from("RUN.md"),
            std::path::PathBuf::from("context-report.json"),
            std::path::PathBuf::from("capability-report.json"),
            std::path::PathBuf::from(".claude/CLAUDE.md"),
            std::path::PathBuf::from(".claude/settings.json"),
            std::path::PathBuf::from(".wren/config.json"),
        ];
        if native_mcp.is_some() {
            paths.push(std::path::PathBuf::from(".mcp.json"));
        }
        if native_host.is_some() {
            paths.push(std::path::PathBuf::from(".warble/component-plans.json"));
        }
        for node in &ir.components {
            paths.push(std::path::PathBuf::from(format!(
                ".claude/agents/{}.md",
                node.verb
            )));
            if should_split_per_step_tier(node)
                && native_host
                    .as_ref()
                    .is_none_or(|host| host.tool(&node.id).is_none())
            {
                for call in &node.llm_calls {
                    paths.push(std::path::PathBuf::from(format!(
                        ".claude/agents/{}.md",
                        subagent_name(&node.verb, call)
                    )));
                }
            }
        }
        Some(crate::interactive::prepare_interactive_output_with_host(
            out_dir,
            target_id,
            "claude",
            &signature,
            &ir.profile,
            &paths,
            purpose,
            native_scope.clone(),
            native_mcp.clone(),
            native_host.clone(),
        )?)
    } else {
        None
    };
    let out_dir = interactive
        .as_ref()
        .map_or(out_dir, |output| output.root.as_path());

    // Write only after the all-cloud resolution pass succeeds, preserving the back-end's
    // abort-before-write contract. Hybrid emitters write the same report after their own gates.
    mkdir_all(out_dir)?;
    write_json(
        &out_dir.join("context-report.json"),
        &serde_json::to_value(context.report()).expect("context report serializes"),
    )?;
    // `.claude/settings.json` is session-scoped: one file the runtime loads once, whichever agent
    // the session selects. So the native MCP allowlist is decided for the profile rather than by
    // whichever component happens to hold the dashboard shape.
    let include_session_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
        && native_mcp.is_some()
        && ir.components.iter().any(|node| {
            is_native_dashboard_component(node) || is_native_persisted_answer_component(node)
        });
    let include_session_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
        && native_mcp.is_some()
        && ir
            .components
            .iter()
            .any(is_native_persisted_answer_component);

    let claude_dir = out_dir.join(".claude");
    let agents_dir = claude_dir.join("agents");
    let wren_dir = out_dir.join(".wren");
    mkdir_all(&agents_dir)?;
    mkdir_all(&wren_dir)?;
    // Each component's own envelope, merged into the session's after the loop. Collected rather
    // than written: a component-scoped write to a session-scoped path is last-writer-wins, not a
    // stricter grant.
    let mut component_settings: Vec<(String, serde_json::Value)> =
        Vec::with_capacity(ir.components.len());

    for node in &ir.components {
        let include_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
            && native_mcp.is_some()
            && (is_native_dashboard_component(node) || is_native_persisted_answer_component(node));
        let include_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
            && native_mcp.is_some()
            && is_native_persisted_answer_component(node);
        let include_native_terminal_presentation = matches!(
            purpose,
            Some(NativePurpose::Analysis | NativePurpose::ContextEnrichment)
        );
        // Native analysis and context enrichment share source IR with programmatic dispatch, but
        // their final terminal response is Markdown rather than the IR's JSON transport. Rewrite
        // only this emitted native view; the IR and every non-native target retain the exact JSON
        // contract.
        let mut rendered_node;
        let node = if include_native_terminal_presentation {
            rendered_node = node.clone();
            rendered_node.prompt_fragment = match purpose {
                Some(NativePurpose::Analysis) => {
                    native_analysis_prompt_fragment(&node.prompt_fragment)
                }
                Some(NativePurpose::ContextEnrichment) => {
                    native_context_enrichment_prompt_fragment(&node.prompt_fragment)
                }
                _ => unreachable!("native terminal presentation requires a native purpose"),
            };
            &rendered_node
        } else {
            node
        };
        let report = report_for(&node.id);

        // Isolation is checked before the per-step split because the two want opposite things from
        // the same component: the split hands each STEP its own child and marshals artifacts through
        // the parent, which is precisely the leakage isolation exists to stop. When both apply the
        // component asked for the boundary, so it wins and the tiers collapse — visibly, in the
        // child's own tier comment and in capability-report.json.
        if let Some(host) = native_host
            .as_ref()
            .filter(|host| host.tool(&node.id).is_some())
        {
            write_file(
                &agents_dir.join(format!("{}.md", node.verb)),
                &host.claude_wrapper(node),
            )?;
            // Preserve the existing top-level scope union envelope. The wrapper's own tools
            // contain only its fixed root admission tool; governed steps never inherit this union.
            let mut settings = if should_split_per_step_tier(node) || should_isolate(node) {
                build_split_settings(
                    node,
                    report,
                    render_flavor,
                    tool_map,
                    include_session_dashboard_save_tool,
                    include_session_persist_answer_tool,
                )
            } else {
                build_settings(
                    node,
                    report,
                    render_flavor,
                    tool_map,
                    false,
                    include_session_dashboard_save_tool,
                    include_session_persist_answer_tool,
                )
            };
            settings["permissions"]["allow"]
                .as_array_mut()
                .expect("settings allow")
                .push(serde_json::json!(format!(
                    "mcp__genbi_session__{}",
                    host.tool(&node.id).expect("root")
                )));
            component_settings.push((node.verb.clone(), settings));
        } else if should_isolate(node) {
            write_file(
                &agents_dir.join(format!("{}.md", node.verb)),
                &build_isolating_parent_markdown(node, report, render_flavor, models)?,
            )?;
            write_file(
                &agents_dir.join(format!("{}.md", isolated_agent_name(&node.verb))),
                &build_isolated_child_markdown(
                    node,
                    report,
                    render_flavor,
                    models,
                    context,
                    tool_map,
                    include_setup_recovery_instructions,
                    include_dashboard_save_tool,
                )?,
            )?;
            // The parent needs Task/Read and the child needs the component's own tools, which is
            // exactly the union the split path already computes.
            component_settings.push((
                node.verb.clone(),
                build_split_settings(
                    node,
                    report,
                    render_flavor,
                    tool_map,
                    include_session_dashboard_save_tool,
                    include_session_persist_answer_tool,
                ),
            ));
        } else if should_split_per_step_tier(node) {
            let mut driver = build_driver_markdown(
                node,
                report,
                render_flavor,
                models,
                context,
                include_dashboard_save_tool,
                include_persist_answer_tool,
                include_native_terminal_presentation,
            );
            if include_persist_answer_tool {
                driver.push('\n');
                driver.push_str(native_answer_persistence_instructions());
            }
            if include_dashboard_save_tool {
                driver.push('\n');
                driver.push_str(native_dashboard_save_instructions());
            }
            if include_native_terminal_presentation {
                driver.push('\n');
                driver.push_str(match purpose {
                    Some(NativePurpose::Analysis) => {
                        native_analysis_terminal_presentation_instructions()
                    }
                    Some(NativePurpose::ContextEnrichment) => {
                        native_context_enrichment_terminal_presentation_instructions()
                    }
                    _ => unreachable!("native terminal presentation requires a native purpose"),
                });
            }
            if purpose == Some(NativePurpose::Setup) {
                driver.push('\n');
                driver.push_str(&setup_bootstrap_authority_instructions());
            }
            write_file(&agents_dir.join(format!("{}.md", node.verb)), &driver)?;
            for call in &node.llm_calls {
                let mut subagent = build_subagent_markdown(node, call, models, context, tool_map);
                if purpose == Some(NativePurpose::Setup) {
                    subagent.push('\n');
                    subagent.push_str(&setup_bootstrap_authority_instructions());
                }
                write_file(
                    &agents_dir.join(format!("{}.md", subagent_name(&node.verb, call))),
                    &subagent,
                )?;
            }
            component_settings.push((
                node.verb.clone(),
                build_split_settings(
                    node,
                    report,
                    render_flavor,
                    tool_map,
                    include_session_dashboard_save_tool,
                    include_session_persist_answer_tool,
                ),
            ));
        } else {
            let mut agent_markdown = build_agent_markdown(
                node,
                report,
                render_flavor,
                models,
                context,
                tool_map,
                include_setup_recovery_instructions,
                include_dashboard_save_tool,
            )?;
            if include_dashboard_save_tool {
                agent_markdown.push('\n');
                agent_markdown.push_str(native_dashboard_save_instructions());
            }
            if include_native_terminal_presentation {
                agent_markdown.push('\n');
                agent_markdown.push_str(match purpose {
                    Some(NativePurpose::Analysis) => {
                        native_analysis_terminal_presentation_instructions()
                    }
                    Some(NativePurpose::ContextEnrichment) => {
                        native_context_enrichment_terminal_presentation_instructions()
                    }
                    _ => unreachable!("native terminal presentation requires a native purpose"),
                });
            }
            if include_setup_recovery_instructions {
                agent_markdown.push('\n');
                agent_markdown.push_str(setup_recovery_instructions());
            }
            if purpose == Some(NativePurpose::Setup) {
                agent_markdown.push('\n');
                agent_markdown.push_str(&setup_bootstrap_authority_instructions());
            }
            write_file(
                &agents_dir.join(format!("{}.md", node.verb)),
                &agent_markdown,
            )?;
            component_settings.push((
                node.verb.clone(),
                build_settings(
                    node,
                    report,
                    render_flavor,
                    tool_map,
                    include_setup_recovery_instructions,
                    include_session_dashboard_save_tool,
                    include_session_persist_answer_tool,
                ),
            ));
        }
    }

    // Scope-level artifacts, computed once for the profile. The runtime loads
    // one settings file, one data-layer config and one project memory per session — emitting them
    // per component writes N times to each path and lets whichever component the IR ends with
    // decide the session's envelope.
    let scope_components = ir
        .components
        .iter()
        .map(|node| (node, report_for(&node.id)))
        .collect::<Vec<_>>();
    write_json(
        &claude_dir.join("settings.json"),
        &native_setup_settings(
            merge_scope_settings(&component_settings),
            purpose,
            native_scope.as_ref(),
        )?,
    )?;
    write_json(&wren_dir.join("config.json"), &wren_config())?;
    let scope_prompt = build_scope_prompt(
        ir,
        &scope_components,
        render_flavor,
        scope_denies_destructive_bash(&component_settings),
    );
    write_file(
        &claude_dir.join("CLAUDE.md"),
        &match interactive.as_ref() {
            Some(output) => format!("{}\n{scope_prompt}", output.marker()),
            None => scope_prompt,
        },
    )?;

    // RUN.md is profile-level: one emitted directory, one document. Writing it inside the loop
    // above would write it once per component to the same path, leaving the last component the only
    // one documented and the rest of the profile invisible.
    let run = match interactive.as_ref() {
        Some(output) => format!(
            "{}\n{}",
            output.marker(),
            build_interactive_run_md(
                ir,
                purpose,
                native_scope
                    .as_ref()
                    .and_then(|scope| scope.entry.pinned_verb())
            )
        ),
        None => build_profile_run_md(&ir.profile, &scope_components, render_flavor, models)?,
    };
    write_file(&out_dir.join("RUN.md"), &run)?;

    if let (Some(_), Some(descriptor)) = (&interactive, native_mcp.as_ref()) {
        write_file(
            &out_dir.join(".mcp.json"),
            &descriptor.claude_discovery_config()?,
        )?;
        // The interactive output remains the sole writer of the ownership manifest and launch
        // spec, after this exact vendor-owned discovery file is present and can be hashed.
    }

    let capability_report = serde_json::json!({
        "target": target_id,
        "components": ir.components.iter().map(|node| {
            let mut entry = serde_json::json!({
                "id": node.id,
                "capabilities": report_for(&node.id),
            });
            // Which provider-supplied tools this component was actually granted, and where each is
            // realized. Without it, a reader of the emitted agent sees tool names with no way to
            // tell which fragment put them there or what backs them.
            let granted: serde_json::Map<String, serde_json::Value> = node
                .required_capabilities
                .iter()
                .filter_map(|c| tool_map.get(c.as_str()).map(|b| (c, b)))
                .map(|(capability, binding)| {
                    (
                        capability.clone(),
                        serde_json::json!({ "names": binding.names, "source": binding.source }),
                    )
                })
                .collect();
            if !granted.is_empty() {
                entry["tool_bindings"] = serde_json::Value::Object(granted);
            }
            // Isolation swallows the per-step-tier split, and swallowing a realized capability is a
            // degrade — so it is recorded rather than left for a reader to infer from the absence of
            // subagent files ("no silent caps", capability-model §4).
            if should_isolate(node) {
                let collapsed = models.collapsed_model(&node.llm_calls).ok();
                entry["isolation"] = serde_json::json!({
                    "realized_via": "single-child-subagent",
                    "child_agent": isolated_agent_name(&node.verb),
                    "per_step_tier": if should_split_per_step_tier(node) {
                        serde_json::json!({
                            "outcome": "degrade",
                            "reason": "the whole component runs in one child, so its steps share one model",
                            "collapsed_to": collapsed,
                        })
                    } else {
                        serde_json::json!({ "outcome": "not-requested" })
                    },
                });
            }
            entry
        }).collect::<Vec<_>>(),
    });
    write_json(&out_dir.join("capability-report.json"), &capability_report)?;

    for node in &ir.components {
        print_resolution_summary(
            &format!("{target_id} (component '{}')", node.id),
            report_for(&node.id),
        );
    }

    if let Some(output) = interactive {
        output.write_ownership()?;
        output.write_launch_spec()?;
    }
    Ok(())
}