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