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::slots::assert_no_slot_references;
55use crate::targets::{CapabilityOutcome, TargetId};
56use std::path::Path;
57
58use agent::build_agent_markdown;
59use fs_util::{mkdir_all, write_file, write_json};
60use hybrid::{
61 any_local_provider, build_hybrid_disclosure, build_hybrid_run_md, emit_hybrid_file_target,
62 emit_hybrid_file_target_mcp,
63};
64use isolate::{
65 build_isolated_child_markdown, build_isolating_parent_markdown, isolated_agent_name,
66 should_isolate,
67};
68use resolution::{print_resolution_summary, resolve_node_with_shared_binding};
69use run_md::{build_interactive_run_md, build_profile_run_md};
70use scope::{build_scope_prompt, merge_scope_settings, scope_denies_destructive_bash};
71use settings::{build_settings, wren_config};
72use split::{
73 build_driver_markdown, build_split_settings, build_subagent_markdown,
74 should_split_per_step_tier, subagent_name,
75};
76use support::{outcome_supported, realization_supported, trigger_supported, unsupported};
77
78fn is_native_dashboard_component(node: &crate::ir::ComponentNode) -> bool {
79 node.required_capabilities
80 .iter()
81 .any(|capability| capability == "genbi_build")
82 && node
83 .required_capabilities
84 .iter()
85 .any(|capability| capability == "artifact_write")
86}
87
88fn is_native_persisted_answer_component(node: &crate::ir::ComponentNode) -> bool {
91 let blocks = &node.effect.render_blocks;
92 let table = blocks
93 .iter()
94 .filter(|block| block.block_type == "table")
95 .count();
96 let definition = blocks
97 .iter()
98 .filter(|block| block.block_type == "definition")
99 .count();
100 table == 1
101 && definition <= 1
102 && blocks.len() == table + definition
103 && blocks
104 .iter()
105 .find(|block| block.block_type == "table")
106 .is_some_and(|block| {
107 block
108 .fields
109 .get("columns")
110 .is_some_and(|field| field == "string[]")
111 && block
112 .fields
113 .get("rows")
114 .is_some_and(|field| field == "row[]")
115 })
116 && blocks
117 .iter()
118 .filter(|block| block.block_type == "definition")
119 .all(|block| {
120 block
121 .fields
122 .get("sql")
123 .is_some_and(|field| field == "string")
124 && block
125 .fields
126 .get("source_tables")
127 .is_some_and(|field| field == "string[]")
128 && block
129 .fields
130 .get("filters")
131 .is_some_and(|field| field == "string[]")
132 })
133}
134
135fn native_setup_settings(
136 mut settings: serde_json::Value,
137 purpose: Option<NativePurpose>,
138 native_scope: Option<&NativeSessionScope>,
139) -> Result<serde_json::Value, DispatchError> {
140 if purpose != Some(NativePurpose::Setup) {
141 return Ok(settings);
142 }
143 let scope = native_scope.ok_or_else(|| {
144 DispatchError("Setup permissions require a server-derived native scope".to_string())
145 })?;
146 let scoped_permissions = scope.claude_setup_write_permissions()?;
147 let allow = settings
148 .pointer_mut("/permissions/allow")
149 .and_then(serde_json::Value::as_array_mut)
150 .ok_or_else(|| DispatchError("native Setup permissions are incompatible".to_string()))?;
151 allow.retain(|entry| !matches!(entry.as_str(), Some("Edit" | "Write")));
152 for permission in scoped_permissions {
153 allow.push(serde_json::Value::String(permission));
154 }
155 let note = "Setup write authority is server-sealed: only Edit/Write paths below the host-provided WARBLE_SETUP_BOOTSTRAP_ROOT are allowed; the native cwd is private and read-only.";
156 match settings.get_mut("$comment") {
157 Some(comment) => {
158 let existing = comment.as_str().ok_or_else(|| {
159 DispatchError("native Setup permissions are incompatible".to_string())
160 })?;
161 *comment = serde_json::Value::String(format!("{existing} {note}"));
162 }
163 None => {
164 settings["$comment"] = serde_json::Value::String(note.to_string());
165 }
166 }
167 Ok(settings)
168}
169
170fn assert_node_has_no_slot_references(
175 node: &crate::ir::ComponentNode,
176) -> Result<(), DispatchError> {
177 if let Some(brief) = node.brief.as_deref() {
178 assert_no_slot_references(brief, &format!("the brief of component '{}'", node.id))?;
179 }
180 assert_no_slot_references(
181 &node.prompt_fragment,
182 &format!("the prompt fragment of component '{}'", node.id),
183 )?;
184 for call in &node.llm_calls {
185 assert_no_slot_references(
186 &call.prompt,
187 &format!("step '{}' of component '{}'", call.name, node.id),
188 )?;
189 }
190 Ok(())
191}
192
193pub fn emit_claude_code(
194 ir: &WarbleIr,
195 out_dir: &Path,
196 target_id: &str,
197 render_flavor: RenderFlavor,
198) -> Result<(), DispatchError> {
199 emit_claude_code_with_models(
200 ir,
201 out_dir,
202 target_id,
203 render_flavor,
204 &ModelConfig::default(),
205 )
206}
207
208fn require_per_step_provider_support(
213 ir: &WarbleIr,
214 target_id: &str,
215 models: &ModelConfig,
216) -> Result<(), DispatchError> {
217 let supported = TargetId::parse(target_id)
218 .map(|t| t.profile())
219 .and_then(|p| p.get("llm:per_step_provider").map(|e| e.outcome))
220 .is_some_and(|outcome| outcome != CapabilityOutcome::Fail);
221 if supported {
222 return Ok(());
223 }
224 for node in &ir.components {
225 for call in &node.llm_calls {
226 let binding = models.binding(&call.tier)?;
227 if binding.provider != ANTHROPIC_PROVIDER {
228 return Err(DispatchError(format!(
229 "llm:per_step_provider: fail on {target_id} — the binding routes step '{}.{}' \
230(tier '{}') to provider '{}', but this target is whole-session single-provider and does not support \
231per-step provider routing (hybrid). Use an all-cloud binding for this target, or dispatch to a target \
232that realizes llm:per_step_provider.",
233 node.verb,
234 call.name,
235 call.tier,
236 binding.provider.as_str()
237 )));
238 }
239 }
240 }
241 Ok(())
242}
243
244pub fn emit_claude_code_with_models(
250 ir: &WarbleIr,
251 out_dir: &Path,
252 target_id: &str,
253 render_flavor: RenderFlavor,
254 models: &ModelConfig,
255) -> Result<(), DispatchError> {
256 emit_claude_code_with_realization(
257 ir,
258 out_dir,
259 target_id,
260 render_flavor,
261 models,
262 HybridRealization::default(),
263 )
264}
265
266pub fn emit_claude_code_with_realization(
269 ir: &WarbleIr,
270 out_dir: &Path,
271 target_id: &str,
272 render_flavor: RenderFlavor,
273 models: &ModelConfig,
274 hybrid: HybridRealization,
275) -> Result<(), DispatchError> {
276 let context = ContextInjection::from_ir(ir, DEFAULT_CONTEXT_INJECTION, None);
277 emit_claude_code_with_context(
278 ir,
279 out_dir,
280 target_id,
281 render_flavor,
282 models,
283 hybrid,
284 &context,
285 )
286}
287
288#[allow(clippy::too_many_arguments)]
291pub fn emit_claude_code_with_context(
292 ir: &WarbleIr,
293 out_dir: &Path,
294 target_id: &str,
295 render_flavor: RenderFlavor,
296 models: &ModelConfig,
297 hybrid: HybridRealization,
298 context: &ContextInjection,
299) -> Result<(), DispatchError> {
300 emit_claude_code_with_providers(
301 ir,
302 out_dir,
303 target_id,
304 render_flavor,
305 models,
306 hybrid,
307 context,
308 &[],
309 )
310}
311
312#[allow(clippy::too_many_arguments)]
316pub fn emit_claude_code_with_providers(
317 ir: &WarbleIr,
318 out_dir: &Path,
319 target_id: &str,
320 render_flavor: RenderFlavor,
321 models: &ModelConfig,
322 hybrid: HybridRealization,
323 context: &ContextInjection,
324 providers: &[ProviderFragment],
325) -> Result<(), DispatchError> {
326 emit_claude_code_with_native_purpose(
327 ir,
328 out_dir,
329 target_id,
330 render_flavor,
331 models,
332 hybrid,
333 context,
334 providers,
335 None,
336 None,
337 None,
338 )
339}
340
341#[allow(clippy::too_many_arguments)]
344pub fn emit_claude_code_with_native_purpose(
345 ir: &WarbleIr,
346 out_dir: &Path,
347 target_id: &str,
348 render_flavor: RenderFlavor,
349 models: &ModelConfig,
350 hybrid: HybridRealization,
351 context: &ContextInjection,
352 providers: &[ProviderFragment],
353 purpose: Option<NativePurpose>,
354 native_scope: Option<NativeSessionScope>,
355 native_mcp: Option<NativeMcpDescriptor>,
356) -> Result<(), DispatchError> {
357 validate_ir_version(ir)?;
358 if let Some(purpose) = purpose {
359 if target_id != "claude-code:interactive" {
360 return Err(DispatchError(
361 "--purpose is supported only by native interactive targets".to_string(),
362 ));
363 }
364 let scope = native_scope.as_ref().ok_or_else(|| {
365 DispatchError(
366 "native Sessions purpose requires a server-derived native scope descriptor"
367 .to_string(),
368 )
369 })?;
370 purpose.validate_profile(ir, &scope.entry)?;
371 }
372 if native_mcp.is_some() && purpose.is_none() {
373 return Err(DispatchError(
374 "--native-mcp requires a native Sessions --purpose".to_string(),
375 ));
376 }
377 let include_setup_recovery_instructions =
378 purpose == Some(NativePurpose::Setup) && native_mcp.is_some();
379 if target_id == "codex:interactive" {
380 return Err(DispatchError("codex:interactive must use the native Codex materializer, never the Claude file emitter".to_string()));
381 }
382 let filtered_ir;
386 let ir = if target_id == "claude-code:interactive" {
387 filtered_ir = WarbleIr {
388 components: ir
389 .components
390 .iter()
391 .filter(|node| {
392 !node
393 .required_capabilities
394 .iter()
395 .any(|capability| capability == "enrichment_apply:deterministic")
396 })
397 .cloned()
398 .collect(),
399 ..ir.clone()
400 };
401 if filtered_ir.components.is_empty() {
402 return Err(DispatchError("apply_enrichment cannot be dispatched on claude-code:interactive: native materialization has no enforceable human-approval apply channel (wall-hit)".to_string()));
403 }
404 &filtered_ir
405 } else {
406 ir
407 };
408 for node in &ir.components {
414 assert_node_has_no_slot_references(node)?;
415 }
416 models.validate(ir)?;
418 if ir
422 .components
423 .iter()
424 .any(|n| should_split_per_step_tier(n) || should_isolate(n))
425 {
426 models.orchestrator()?;
427 }
428 require_per_step_provider_support(ir, target_id, models)?;
434
435 for node in &ir.components {
437 if !realization_supported(node.realization_kind) {
438 return Err(unsupported(
439 "realization_kind",
440 node.realization_kind.as_str(),
441 ));
442 }
443 if !trigger_supported(node.trigger.kind) {
444 return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
445 }
446 if !outcome_supported(node.effect.outcome.kind) {
447 return Err(unsupported(
448 "outcome.kind",
449 node.effect.outcome.kind.as_str(),
450 ));
451 }
452 }
453
454 let target = TargetId::parse(target_id).ok_or_else(|| {
459 DispatchError(format!(
460 "target '{target_id}' has no capability profile (known targets: {})",
461 crate::targets::known_target_names().join(", ")
462 ))
463 })?;
464 let composed = compose_target(target.profile(), ToolMap::new(), providers, target)?;
465 let tool_map = &composed.tool_map;
466
467 let mut reports: Vec<(String, ResolutionReport)> = Vec::with_capacity(ir.components.len());
469 for node in &ir.components {
470 if node.realization_kind == RealizationKind::GatedTool && should_split_per_step_tier(node) {
487 return Err(DispatchError(format!(
488 "llm:per_step_tier: gated-tool component '{}' has divergent step tiers, but per-step splitting would grant every subagent the mutation guardrail's write/edit authority alongside the approval-gated driver, duplicating write access outside the two-phase approval lifecycle (dry-run diff -> blast-radius -> human approval -> apply) — refusing to dispatch rather than silently collapsing the tiers or unsafely splitting write authority. Realize this component as `tool` (no approval gate) or `skill` to enable per-step-tier splitting, or author it with a single tier to keep it a `gated-tool`.",
489 node.verb
490 )));
491 }
492 reports.push((
493 node.id.clone(),
494 resolve_node_with_shared_binding(
495 node,
496 &ir.context_binding,
497 target_id,
498 &composed.profile,
499 )?,
500 ));
501 }
502 let report_for = |id: &str| -> &ResolutionReport {
503 &reports
504 .iter()
505 .find(|(nid, _)| nid == id)
506 .expect("report exists")
507 .1
508 };
509
510 if any_local_provider(ir, models)? {
514 if native_mcp.is_some() {
515 return Err(DispatchError(
516 "native MCP discovery requires an all-cloud native interactive materialization; hybrid MCP configuration is a separate producer contract"
517 .to_string(),
518 ));
519 }
520 let scope_components = ir
521 .components
522 .iter()
523 .map(|node| (node, report_for(&node.id)))
524 .collect::<Vec<_>>();
525 let disclosure = build_hybrid_disclosure(ir, models, hybrid)?;
526 let scope_prompt = format!(
527 "{}{}",
528 build_scope_prompt(ir, &scope_components, render_flavor, true),
529 disclosure
530 );
531 let run = build_hybrid_run_md(ir, target_id, &disclosure);
532 match hybrid {
533 HybridRealization::BashScript => {
534 emit_hybrid_file_target(ir, out_dir, target_id, models, context)?
535 }
536 HybridRealization::McpServer => {
537 emit_hybrid_file_target_mcp(ir, out_dir, target_id, models, context)?
538 }
539 }
540 write_file(&out_dir.join(".claude/CLAUDE.md"), &scope_prompt)?;
541 write_file(&out_dir.join("RUN.md"), &run)?;
542 return Ok(());
543 }
544
545 let interactive = if target_id == "claude-code:interactive" {
548 let signature = ir
549 .components
550 .iter()
551 .map(|node| node.id.as_str())
552 .collect::<Vec<_>>()
553 .join(",");
554 let mut paths = vec![
555 std::path::PathBuf::from("RUN.md"),
556 std::path::PathBuf::from("context-report.json"),
557 std::path::PathBuf::from("capability-report.json"),
558 std::path::PathBuf::from(".claude/CLAUDE.md"),
559 std::path::PathBuf::from(".claude/settings.json"),
560 std::path::PathBuf::from(".wren/config.json"),
561 ];
562 if native_mcp.is_some() {
563 paths.push(std::path::PathBuf::from(".mcp.json"));
564 }
565 for node in &ir.components {
566 paths.push(std::path::PathBuf::from(format!(
567 ".claude/agents/{}.md",
568 node.verb
569 )));
570 if should_split_per_step_tier(node) {
571 for call in &node.llm_calls {
572 paths.push(std::path::PathBuf::from(format!(
573 ".claude/agents/{}.md",
574 subagent_name(&node.verb, call)
575 )));
576 }
577 }
578 }
579 Some(prepare_interactive_output(
580 out_dir,
581 target_id,
582 "claude",
583 &signature,
584 &ir.profile,
585 &paths,
586 purpose,
587 native_scope.clone(),
588 native_mcp.clone(),
589 )?)
590 } else {
591 None
592 };
593 let out_dir = interactive
594 .as_ref()
595 .map_or(out_dir, |output| output.root.as_path());
596
597 mkdir_all(out_dir)?;
600 write_json(
601 &out_dir.join("context-report.json"),
602 &serde_json::to_value(context.report()).expect("context report serializes"),
603 )?;
604 let include_session_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
608 && native_mcp.is_some()
609 && ir.components.iter().any(is_native_dashboard_component);
610 let include_session_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
611 && native_mcp.is_some()
612 && ir
613 .components
614 .iter()
615 .any(is_native_persisted_answer_component);
616
617 let claude_dir = out_dir.join(".claude");
618 let agents_dir = claude_dir.join("agents");
619 let wren_dir = out_dir.join(".wren");
620 mkdir_all(&agents_dir)?;
621 mkdir_all(&wren_dir)?;
622 let mut component_settings: Vec<(String, serde_json::Value)> =
626 Vec::with_capacity(ir.components.len());
627
628 for node in &ir.components {
629 let include_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
630 && native_mcp.is_some()
631 && (is_native_dashboard_component(node) || is_native_persisted_answer_component(node));
632 let include_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
633 && native_mcp.is_some()
634 && is_native_persisted_answer_component(node);
635 let include_native_terminal_presentation = matches!(
636 purpose,
637 Some(NativePurpose::Analysis | NativePurpose::ContextEnrichment)
638 );
639 let mut rendered_node;
644 let node = if include_native_terminal_presentation {
645 rendered_node = node.clone();
646 rendered_node.prompt_fragment = match purpose {
647 Some(NativePurpose::Analysis) => {
648 native_analysis_prompt_fragment(&node.prompt_fragment)
649 }
650 Some(NativePurpose::ContextEnrichment) => {
651 native_context_enrichment_prompt_fragment(&node.prompt_fragment)
652 }
653 _ => unreachable!("native terminal presentation requires a native purpose"),
654 };
655 &rendered_node
656 } else {
657 node
658 };
659 let report = report_for(&node.id);
660
661 if should_isolate(node) {
667 write_file(
668 &agents_dir.join(format!("{}.md", node.verb)),
669 &build_isolating_parent_markdown(node, report, render_flavor, models)?,
670 )?;
671 write_file(
672 &agents_dir.join(format!("{}.md", isolated_agent_name(&node.verb))),
673 &build_isolated_child_markdown(
674 node,
675 report,
676 render_flavor,
677 models,
678 context,
679 tool_map,
680 include_setup_recovery_instructions,
681 include_dashboard_save_tool,
682 )?,
683 )?;
684 component_settings.push((
687 node.verb.clone(),
688 build_split_settings(
689 node,
690 report,
691 render_flavor,
692 tool_map,
693 include_session_dashboard_save_tool,
694 include_session_persist_answer_tool,
695 ),
696 ));
697 } else if should_split_per_step_tier(node) {
698 let mut driver = build_driver_markdown(
699 node,
700 report,
701 render_flavor,
702 models,
703 context,
704 include_dashboard_save_tool,
705 include_persist_answer_tool,
706 include_native_terminal_presentation,
707 );
708 if include_persist_answer_tool {
709 driver.push('\n');
710 driver.push_str(native_answer_persistence_instructions());
711 }
712 if include_dashboard_save_tool {
713 driver.push('\n');
714 driver.push_str(native_dashboard_save_instructions());
715 }
716 if include_native_terminal_presentation {
717 driver.push('\n');
718 driver.push_str(match purpose {
719 Some(NativePurpose::Analysis) => {
720 native_analysis_terminal_presentation_instructions()
721 }
722 Some(NativePurpose::ContextEnrichment) => {
723 native_context_enrichment_terminal_presentation_instructions()
724 }
725 _ => unreachable!("native terminal presentation requires a native purpose"),
726 });
727 }
728 if purpose == Some(NativePurpose::Setup) {
729 driver.push('\n');
730 driver.push_str(&setup_bootstrap_authority_instructions());
731 }
732 write_file(&agents_dir.join(format!("{}.md", node.verb)), &driver)?;
733 for call in &node.llm_calls {
734 let mut subagent = build_subagent_markdown(node, call, models, context, tool_map);
735 if purpose == Some(NativePurpose::Setup) {
736 subagent.push('\n');
737 subagent.push_str(&setup_bootstrap_authority_instructions());
738 }
739 write_file(
740 &agents_dir.join(format!("{}.md", subagent_name(&node.verb, call))),
741 &subagent,
742 )?;
743 }
744 component_settings.push((
745 node.verb.clone(),
746 build_split_settings(
747 node,
748 report,
749 render_flavor,
750 tool_map,
751 include_session_dashboard_save_tool,
752 include_session_persist_answer_tool,
753 ),
754 ));
755 } else {
756 let mut agent_markdown = build_agent_markdown(
757 node,
758 report,
759 render_flavor,
760 models,
761 context,
762 tool_map,
763 include_setup_recovery_instructions,
764 include_dashboard_save_tool,
765 )?;
766 if include_dashboard_save_tool {
767 agent_markdown.push('\n');
768 agent_markdown.push_str(native_dashboard_save_instructions());
769 }
770 if include_native_terminal_presentation {
771 agent_markdown.push('\n');
772 agent_markdown.push_str(match purpose {
773 Some(NativePurpose::Analysis) => {
774 native_analysis_terminal_presentation_instructions()
775 }
776 Some(NativePurpose::ContextEnrichment) => {
777 native_context_enrichment_terminal_presentation_instructions()
778 }
779 _ => unreachable!("native terminal presentation requires a native purpose"),
780 });
781 }
782 if include_setup_recovery_instructions {
783 agent_markdown.push('\n');
784 agent_markdown.push_str(setup_recovery_instructions());
785 }
786 if purpose == Some(NativePurpose::Setup) {
787 agent_markdown.push('\n');
788 agent_markdown.push_str(&setup_bootstrap_authority_instructions());
789 }
790 write_file(
791 &agents_dir.join(format!("{}.md", node.verb)),
792 &agent_markdown,
793 )?;
794 component_settings.push((
795 node.verb.clone(),
796 build_settings(
797 node,
798 report,
799 render_flavor,
800 tool_map,
801 include_setup_recovery_instructions,
802 include_session_dashboard_save_tool,
803 include_session_persist_answer_tool,
804 ),
805 ));
806 }
807 }
808
809 let scope_components = ir
814 .components
815 .iter()
816 .map(|node| (node, report_for(&node.id)))
817 .collect::<Vec<_>>();
818 write_json(
819 &claude_dir.join("settings.json"),
820 &native_setup_settings(
821 merge_scope_settings(&component_settings),
822 purpose,
823 native_scope.as_ref(),
824 )?,
825 )?;
826 write_json(&wren_dir.join("config.json"), &wren_config())?;
827 let scope_prompt = build_scope_prompt(
828 ir,
829 &scope_components,
830 render_flavor,
831 scope_denies_destructive_bash(&component_settings),
832 );
833 write_file(
834 &claude_dir.join("CLAUDE.md"),
835 &match interactive.as_ref() {
836 Some(output) => format!("{}\n{scope_prompt}", output.marker()),
837 None => scope_prompt,
838 },
839 )?;
840
841 let run = match interactive.as_ref() {
845 Some(output) => format!(
846 "{}\n{}",
847 output.marker(),
848 build_interactive_run_md(
849 ir,
850 purpose,
851 native_scope
852 .as_ref()
853 .and_then(|scope| scope.entry.pinned_verb())
854 )
855 ),
856 None => build_profile_run_md(&ir.profile, &scope_components, render_flavor, models)?,
857 };
858 write_file(&out_dir.join("RUN.md"), &run)?;
859
860 if let (Some(_), Some(descriptor)) = (&interactive, native_mcp.as_ref()) {
861 write_file(
862 &out_dir.join(".mcp.json"),
863 &descriptor.claude_discovery_config()?,
864 )?;
865 }
868
869 let capability_report = serde_json::json!({
870 "target": target_id,
871 "components": ir.components.iter().map(|node| {
872 let mut entry = serde_json::json!({
873 "id": node.id,
874 "capabilities": report_for(&node.id),
875 });
876 let granted: serde_json::Map<String, serde_json::Value> = node
880 .required_capabilities
881 .iter()
882 .filter_map(|c| tool_map.get(c.as_str()).map(|b| (c, b)))
883 .map(|(capability, binding)| {
884 (
885 capability.clone(),
886 serde_json::json!({ "names": binding.names, "source": binding.source }),
887 )
888 })
889 .collect();
890 if !granted.is_empty() {
891 entry["tool_bindings"] = serde_json::Value::Object(granted);
892 }
893 if should_isolate(node) {
897 let collapsed = models.collapsed_model(&node.llm_calls).ok();
898 entry["isolation"] = serde_json::json!({
899 "realized_via": "single-child-subagent",
900 "child_agent": isolated_agent_name(&node.verb),
901 "per_step_tier": if should_split_per_step_tier(node) {
902 serde_json::json!({
903 "outcome": "degrade",
904 "reason": "the whole component runs in one child, so its steps share one model",
905 "collapsed_to": collapsed,
906 })
907 } else {
908 serde_json::json!({ "outcome": "not-requested" })
909 },
910 });
911 }
912 entry
913 }).collect::<Vec<_>>(),
914 });
915 write_json(&out_dir.join("capability-report.json"), &capability_report)?;
916
917 for node in &ir.components {
918 print_resolution_summary(
919 &format!("{target_id} (component '{}')", node.id),
920 report_for(&node.id),
921 );
922 }
923
924 if let Some(output) = interactive {
925 output.write_ownership()?;
926 output.write_launch_spec()?;
927 }
928 Ok(())
929}