1mod 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
87fn 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
184fn 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
220pub 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
242pub 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#[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#[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#[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 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 models.validate(ir)?;
386 if ir
390 .components
391 .iter()
392 .any(|n| should_split_per_step_tier(n) || should_isolate(n))
393 {
394 models.orchestrator()?;
395 }
396 require_per_step_provider_support(ir, target_id, models)?;
402
403 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 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 let mut reports: Vec<(String, ResolutionReport)> = Vec::with_capacity(ir.components.len());
437 for node in &ir.components {
438 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 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 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 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 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 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 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 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 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 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 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 }
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 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 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}