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, setup_bootstrap_authority_instructions,
47 setup_recovery_instructions, NativeMcpDescriptor, NativePurpose, NativeSessionScope,
48};
49use crate::ir::{
50 reject_unsupported_component_composition, validate_ir_version, RealizationKind, WarbleIr,
51};
52use crate::models::{ModelConfig, ANTHROPIC_PROVIDER};
53use crate::provider::{compose_target, ProviderFragment, ToolMap};
54use crate::resolve::ResolutionReport;
55use crate::slots::assert_no_slot_references;
56use crate::targets::{CapabilityOutcome, TargetId};
57use std::path::Path;
58
59use agent::build_agent_markdown;
60use fs_util::{mkdir_all, write_file, write_json};
61use hybrid::{
62 any_local_provider, build_hybrid_disclosure, build_hybrid_run_md, emit_hybrid_file_target,
63 emit_hybrid_file_target_mcp,
64};
65use isolate::{
66 build_isolated_child_markdown, build_isolating_parent_markdown, isolated_agent_name,
67 should_isolate,
68};
69use resolution::{print_resolution_summary, resolve_node_with_shared_binding};
70use run_md::{build_interactive_run_md, build_profile_run_md};
71use scope::{build_scope_prompt, merge_scope_settings, scope_denies_destructive_bash};
72use settings::{build_settings, wren_config};
73use split::{
74 build_driver_markdown, build_split_settings, build_subagent_markdown,
75 should_split_per_step_tier, subagent_name,
76};
77use support::{outcome_supported, realization_supported, trigger_supported, unsupported};
78
79fn is_native_dashboard_component(node: &crate::ir::ComponentNode) -> bool {
80 node.required_capabilities
81 .iter()
82 .any(|capability| capability == "genbi_build")
83 && node
84 .required_capabilities
85 .iter()
86 .any(|capability| capability == "artifact_write")
87}
88
89fn is_native_persisted_answer_component(node: &crate::ir::ComponentNode) -> bool {
92 let blocks = &node.effect.render_blocks;
93 let table = blocks
94 .iter()
95 .filter(|block| block.block_type == "table")
96 .count();
97 let definition = blocks
98 .iter()
99 .filter(|block| block.block_type == "definition")
100 .count();
101 table == 1
102 && definition <= 1
103 && blocks.len() == table + definition
104 && blocks
105 .iter()
106 .find(|block| block.block_type == "table")
107 .is_some_and(|block| {
108 block
109 .fields
110 .get("columns")
111 .is_some_and(|field| field == "string[]")
112 && block
113 .fields
114 .get("rows")
115 .is_some_and(|field| field == "row[]")
116 })
117 && blocks
118 .iter()
119 .filter(|block| block.block_type == "definition")
120 .all(|block| {
121 block
122 .fields
123 .get("sql")
124 .is_some_and(|field| field == "string")
125 && block
126 .fields
127 .get("source_tables")
128 .is_some_and(|field| field == "string[]")
129 && block
130 .fields
131 .get("filters")
132 .is_some_and(|field| field == "string[]")
133 })
134}
135
136fn native_setup_settings(
137 mut settings: serde_json::Value,
138 purpose: Option<NativePurpose>,
139 native_scope: Option<&NativeSessionScope>,
140) -> Result<serde_json::Value, DispatchError> {
141 if purpose != Some(NativePurpose::Setup) {
142 return Ok(settings);
143 }
144 let scope = native_scope.ok_or_else(|| {
145 DispatchError("Setup permissions require a server-derived native scope".to_string())
146 })?;
147 let scoped_permissions = scope.claude_setup_write_permissions()?;
148 let allow = settings
149 .pointer_mut("/permissions/allow")
150 .and_then(serde_json::Value::as_array_mut)
151 .ok_or_else(|| DispatchError("native Setup permissions are incompatible".to_string()))?;
152 allow.retain(|entry| !matches!(entry.as_str(), Some("Edit" | "Write")));
153 for permission in scoped_permissions {
154 allow.push(serde_json::Value::String(permission));
155 }
156 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.";
157 match settings.get_mut("$comment") {
158 Some(comment) => {
159 let existing = comment.as_str().ok_or_else(|| {
160 DispatchError("native Setup permissions are incompatible".to_string())
161 })?;
162 *comment = serde_json::Value::String(format!("{existing} {note}"));
163 }
164 None => {
165 settings["$comment"] = serde_json::Value::String(note.to_string());
166 }
167 }
168 Ok(settings)
169}
170
171fn assert_node_has_no_slot_references(
176 node: &crate::ir::ComponentNode,
177) -> Result<(), DispatchError> {
178 if let Some(brief) = node.brief.as_deref() {
179 assert_no_slot_references(brief, &format!("the brief of component '{}'", node.id))?;
180 }
181 assert_no_slot_references(
182 &node.prompt_fragment,
183 &format!("the prompt fragment of component '{}'", node.id),
184 )?;
185 for call in &node.llm_calls {
186 assert_no_slot_references(
187 &call.prompt,
188 &format!("step '{}' of component '{}'", call.name, node.id),
189 )?;
190 }
191 Ok(())
192}
193
194pub fn emit_claude_code(
195 ir: &WarbleIr,
196 out_dir: &Path,
197 target_id: &str,
198 render_flavor: RenderFlavor,
199) -> Result<(), DispatchError> {
200 emit_claude_code_with_models(
201 ir,
202 out_dir,
203 target_id,
204 render_flavor,
205 &ModelConfig::default(),
206 )
207}
208
209fn require_per_step_provider_support(
214 ir: &WarbleIr,
215 target_id: &str,
216 models: &ModelConfig,
217) -> Result<(), DispatchError> {
218 let supported = TargetId::parse(target_id)
219 .map(|t| t.profile())
220 .and_then(|p| p.get("llm:per_step_provider").map(|e| e.outcome))
221 .is_some_and(|outcome| outcome != CapabilityOutcome::Fail);
222 if supported {
223 return Ok(());
224 }
225 for node in &ir.components {
226 for call in &node.llm_calls {
227 let binding = models.binding(&call.tier)?;
228 if binding.provider != ANTHROPIC_PROVIDER {
229 return Err(DispatchError(format!(
230 "llm:per_step_provider: fail on {target_id} — the binding routes step '{}.{}' \
231(tier '{}') to provider '{}', but this target is whole-session single-provider and does not support \
232per-step provider routing (hybrid). Use an all-cloud binding for this target, or dispatch to a target \
233that realizes llm:per_step_provider.",
234 node.verb,
235 call.name,
236 call.tier,
237 binding.provider.as_str()
238 )));
239 }
240 }
241 }
242 Ok(())
243}
244
245pub fn emit_claude_code_with_models(
251 ir: &WarbleIr,
252 out_dir: &Path,
253 target_id: &str,
254 render_flavor: RenderFlavor,
255 models: &ModelConfig,
256) -> Result<(), DispatchError> {
257 emit_claude_code_with_realization(
258 ir,
259 out_dir,
260 target_id,
261 render_flavor,
262 models,
263 HybridRealization::default(),
264 )
265}
266
267pub fn emit_claude_code_with_realization(
270 ir: &WarbleIr,
271 out_dir: &Path,
272 target_id: &str,
273 render_flavor: RenderFlavor,
274 models: &ModelConfig,
275 hybrid: HybridRealization,
276) -> Result<(), DispatchError> {
277 let context = ContextInjection::from_ir(ir, DEFAULT_CONTEXT_INJECTION);
278 emit_claude_code_with_context(
279 ir,
280 out_dir,
281 target_id,
282 render_flavor,
283 models,
284 hybrid,
285 &context,
286 )
287}
288
289#[allow(clippy::too_many_arguments)]
293pub fn emit_claude_code_with_context(
294 ir: &WarbleIr,
295 out_dir: &Path,
296 target_id: &str,
297 render_flavor: RenderFlavor,
298 models: &ModelConfig,
299 hybrid: HybridRealization,
300 context: &ContextInjection,
301) -> Result<(), DispatchError> {
302 emit_claude_code_with_providers(
303 ir,
304 out_dir,
305 target_id,
306 render_flavor,
307 models,
308 hybrid,
309 context,
310 &[],
311 )
312}
313
314#[allow(clippy::too_many_arguments)]
318pub fn emit_claude_code_with_providers(
319 ir: &WarbleIr,
320 out_dir: &Path,
321 target_id: &str,
322 render_flavor: RenderFlavor,
323 models: &ModelConfig,
324 hybrid: HybridRealization,
325 context: &ContextInjection,
326 providers: &[ProviderFragment],
327) -> Result<(), DispatchError> {
328 emit_claude_code_with_native_purpose(
329 ir,
330 out_dir,
331 target_id,
332 render_flavor,
333 models,
334 hybrid,
335 context,
336 providers,
337 None,
338 None,
339 None,
340 )
341}
342
343#[allow(clippy::too_many_arguments)]
346pub fn emit_claude_code_with_native_purpose(
347 ir: &WarbleIr,
348 out_dir: &Path,
349 target_id: &str,
350 render_flavor: RenderFlavor,
351 models: &ModelConfig,
352 hybrid: HybridRealization,
353 context: &ContextInjection,
354 providers: &[ProviderFragment],
355 purpose: Option<NativePurpose>,
356 native_scope: Option<NativeSessionScope>,
357 native_mcp: Option<NativeMcpDescriptor>,
358) -> Result<(), DispatchError> {
359 emit_claude_code_with_native_host(
360 ir,
361 out_dir,
362 target_id,
363 render_flavor,
364 models,
365 hybrid,
366 context,
367 providers,
368 purpose,
369 native_scope,
370 native_mcp,
371 None,
372 )
373}
374
375#[allow(clippy::too_many_arguments)]
377pub fn emit_claude_code_with_native_host(
378 ir: &WarbleIr,
379 out_dir: &Path,
380 target_id: &str,
381 render_flavor: RenderFlavor,
382 models: &ModelConfig,
383 hybrid: HybridRealization,
384 context: &ContextInjection,
385 providers: &[ProviderFragment],
386 purpose: Option<NativePurpose>,
387 native_scope: Option<NativeSessionScope>,
388 native_mcp: Option<NativeMcpDescriptor>,
389 native_host: Option<crate::native_host::NativeHost>,
390) -> Result<(), DispatchError> {
391 validate_ir_version(ir)?;
392 if context.absent() != ir.context_binding.absent {
393 return Err(DispatchError::new(
394 "context injection must agree with IR context absence",
395 ));
396 }
397 if ir.context_binding.absent {
398 if !matches!(
399 target_id,
400 "claude-code:headless" | "claude-code:interactive"
401 ) || purpose.is_some()
402 || native_scope.is_some()
403 || native_mcp.is_some()
404 || native_host.is_some()
405 || !providers.is_empty()
406 {
407 return Err(DispatchError::new("context-free IR requires a plain native Claude Code file target without provider/native-session bindings"));
408 }
409 for node in &ir.components {
410 if let Some(param) = node.params.iter().find(|param| param.source.is_some()) {
411 return Err(DispatchError::new(format!(
412 "component '{}': runtime-injected parameter '{}' is not supported by context-free native file target '{target_id}'",
413 node.id, param.name
414 )));
415 }
416 let simple = node.component_type == crate::ir::ComponentType::Analytical
417 && node.realization_kind == crate::ir::RealizationKind::Skill
418 && node.trigger.kind == crate::ir::TriggerKind::OneShot
419 && node.effect.outcome.kind == crate::ir::OutcomeKind::None
420 && node.effect.render_blocks.is_empty()
421 && node.context_requirements.is_empty()
422 && node.context_precondition.is_empty()
423 && node.precondition_result.checks.is_empty()
424 && node.borrowed_actions.is_empty()
425 && node.guardrails.len() == 1
426 && node.guardrails[0].name == "read_only_execution"
427 && node.guardrails[0].locked
428 && node.guardrails[0].scope.is_none()
429 && node.guardrails[0].threshold.is_none()
430 && node.llm_calls.len() == 1
431 && !node.llm_calls[0].conditional
432 && node.llm_calls[0].when.is_none()
433 && node.llm_calls[0].component_calls.is_empty()
434 && node.llm_calls[0].consumes.is_empty()
435 && node
436 .required_capabilities
437 .iter()
438 .all(|c| c.starts_with("llm:"));
439 if !simple {
440 return Err(DispatchError::new(format!("component '{}': context-free execution on '{target_id}' supports only a single-step read-only analytical skill with LLM capabilities and no data requirements or effects", node.id)));
441 }
442 if models.binding(&node.llm_calls[0].tier)?.provider
443 != crate::models::ANTHROPIC_PROVIDER
444 {
445 return Err(DispatchError::new(
446 "context-free CLI skills do not support hybrid providers",
447 ));
448 }
449 }
450 }
451 if let Some(host) = &native_host {
452 host.validate(
453 ir,
454 target_id,
455 purpose,
456 native_scope.as_ref(),
457 native_mcp.is_some(),
458 )?;
459 }
460 let mut entry_ir = WarbleIr {
461 components: ir
462 .components
463 .iter()
464 .filter(|node| node.entrypoint)
465 .cloned()
466 .collect(),
467 ..ir.clone()
468 };
469 if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
471 purpose.validate_profile(&entry_ir, &scope.entry)?;
472 }
473 if let Some(verb) = native_scope
474 .as_ref()
475 .and_then(|scope| scope.entry.pinned_verb())
476 {
477 entry_ir.components.retain(|node| node.id == verb);
478 }
479 let ir = &entry_ir;
480 if native_host.is_none() {
481 reject_unsupported_component_composition(ir, target_id)?;
482 }
483 if let Some(purpose) = purpose {
484 if target_id != "claude-code:interactive" {
485 return Err(DispatchError(
486 "--purpose is supported only by native interactive targets".to_string(),
487 ));
488 }
489 let scope = native_scope.as_ref().ok_or_else(|| {
490 DispatchError(
491 "native Sessions purpose requires a server-derived native scope descriptor"
492 .to_string(),
493 )
494 })?;
495 purpose.validate_profile(ir, &scope.entry)?;
496 }
497 if native_mcp.is_some() && purpose.is_none() {
498 return Err(DispatchError(
499 "--native-mcp requires a native Sessions --purpose".to_string(),
500 ));
501 }
502 let include_setup_recovery_instructions =
503 purpose == Some(NativePurpose::Setup) && native_mcp.is_some();
504 if target_id == "codex:interactive" {
505 return Err(DispatchError("codex:interactive must use the native Codex materializer, never the Claude file emitter".to_string()));
506 }
507 let filtered_ir;
511 let ir = if target_id == "claude-code:interactive" {
512 filtered_ir = WarbleIr {
513 components: ir
514 .components
515 .iter()
516 .filter(|node| {
517 !node
518 .required_capabilities
519 .iter()
520 .any(|capability| capability == "enrichment_apply:deterministic")
521 })
522 .cloned()
523 .collect(),
524 ..ir.clone()
525 };
526 if filtered_ir.components.is_empty() {
527 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()));
528 }
529 &filtered_ir
530 } else {
531 ir
532 };
533 for node in &ir.components {
539 assert_node_has_no_slot_references(node)?;
540 }
541 models.validate(ir)?;
543 if ir
547 .components
548 .iter()
549 .any(|n| should_split_per_step_tier(n) || should_isolate(n))
550 {
551 models.orchestrator()?;
552 }
553 require_per_step_provider_support(ir, target_id, models)?;
559
560 for node in &ir.components {
562 if !realization_supported(node.realization_kind) {
563 return Err(unsupported(
564 "realization_kind",
565 node.realization_kind.as_str(),
566 ));
567 }
568 if !trigger_supported(node.trigger.kind) {
569 return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
570 }
571 if !outcome_supported(node.effect.outcome.kind) {
572 return Err(unsupported(
573 "outcome.kind",
574 node.effect.outcome.kind.as_str(),
575 ));
576 }
577 }
578
579 let target = TargetId::parse(target_id).ok_or_else(|| {
584 DispatchError(format!(
585 "target '{target_id}' has no capability profile (known targets: {})",
586 crate::targets::known_target_names().join(", ")
587 ))
588 })?;
589 let composed = compose_target(target.profile(), ToolMap::new(), providers, target)?;
590 let tool_map = &composed.tool_map;
591
592 let mut reports: Vec<(String, ResolutionReport)> = Vec::with_capacity(ir.components.len());
594 for node in &ir.components {
595 if node.realization_kind == RealizationKind::GatedTool && should_split_per_step_tier(node) {
612 return Err(DispatchError(format!(
613 "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`.",
614 node.verb
615 )));
616 }
617 if native_host
618 .as_ref()
619 .is_some_and(|host| host.tool(&node.id).is_some())
620 {
621 reports.push((node.id.clone(), crate::native_host::resolution_report(node)));
622 continue;
623 }
624 reports.push((
625 node.id.clone(),
626 resolve_node_with_shared_binding(
627 node,
628 &ir.context_binding,
629 target_id,
630 &composed.profile,
631 )?,
632 ));
633 }
634 let report_for = |id: &str| -> &ResolutionReport {
635 &reports
636 .iter()
637 .find(|(nid, _)| nid == id)
638 .expect("report exists")
639 .1
640 };
641
642 if any_local_provider(ir, models)? {
646 if native_mcp.is_some() {
647 return Err(DispatchError(
648 "native MCP discovery requires an all-cloud native interactive materialization; hybrid MCP configuration is a separate producer contract"
649 .to_string(),
650 ));
651 }
652 let scope_components = ir
653 .components
654 .iter()
655 .map(|node| (node, report_for(&node.id)))
656 .collect::<Vec<_>>();
657 let disclosure = build_hybrid_disclosure(ir, models, hybrid)?;
658 let scope_prompt = format!(
659 "{}{}",
660 build_scope_prompt(ir, &scope_components, render_flavor, true),
661 disclosure
662 );
663 let run = build_hybrid_run_md(ir, target_id, &disclosure);
664 match hybrid {
665 HybridRealization::BashScript => {
666 emit_hybrid_file_target(ir, out_dir, target_id, models, context)?
667 }
668 HybridRealization::McpServer => {
669 emit_hybrid_file_target_mcp(ir, out_dir, target_id, models, context)?
670 }
671 }
672 write_file(&out_dir.join(".claude/CLAUDE.md"), &scope_prompt)?;
673 write_file(&out_dir.join("RUN.md"), &run)?;
674 return Ok(());
675 }
676
677 let interactive = if target_id == "claude-code:interactive" {
680 let signature = ir
681 .components
682 .iter()
683 .map(|node| node.id.as_str())
684 .collect::<Vec<_>>()
685 .join(",");
686 let mut paths = vec![
687 std::path::PathBuf::from("RUN.md"),
688 std::path::PathBuf::from("context-report.json"),
689 std::path::PathBuf::from("capability-report.json"),
690 std::path::PathBuf::from(".claude/CLAUDE.md"),
691 std::path::PathBuf::from(".claude/settings.json"),
692 std::path::PathBuf::from(".wren/config.json"),
693 ];
694 if ir.context_binding.absent {
695 paths.retain(|path| path != std::path::Path::new(".wren/config.json"));
696 }
697 if native_mcp.is_some() {
698 paths.push(std::path::PathBuf::from(".mcp.json"));
699 }
700 if native_host.is_some() {
701 paths.push(std::path::PathBuf::from(".warble/component-plans.json"));
702 }
703 for node in &ir.components {
704 paths.push(std::path::PathBuf::from(format!(
705 ".claude/agents/{}.md",
706 node.verb
707 )));
708 if should_split_per_step_tier(node)
709 && native_host
710 .as_ref()
711 .is_none_or(|host| host.tool(&node.id).is_none())
712 {
713 for call in &node.llm_calls {
714 paths.push(std::path::PathBuf::from(format!(
715 ".claude/agents/{}.md",
716 subagent_name(&node.verb, call)
717 )));
718 }
719 }
720 }
721 Some(crate::interactive::prepare_interactive_output_with_host(
722 out_dir,
723 target_id,
724 "claude",
725 &signature,
726 &ir.profile,
727 &paths,
728 purpose,
729 native_scope.clone(),
730 native_mcp.clone(),
731 native_host.clone(),
732 None,
733 )?)
734 } else {
735 None
736 };
737 let out_dir = interactive
738 .as_ref()
739 .map_or(out_dir, |output| output.root.as_path());
740
741 mkdir_all(out_dir)?;
744 write_json(
745 &out_dir.join("context-report.json"),
746 &serde_json::to_value(context.report()).expect("context report serializes"),
747 )?;
748 let include_session_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
752 && native_mcp.is_some()
753 && ir.components.iter().any(|node| {
754 is_native_dashboard_component(node) || is_native_persisted_answer_component(node)
755 });
756 let include_session_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
757 && native_mcp.is_some()
758 && ir
759 .components
760 .iter()
761 .any(is_native_persisted_answer_component);
762
763 let claude_dir = out_dir.join(".claude");
764 let agents_dir = claude_dir.join("agents");
765 let wren_dir = out_dir.join(".wren");
766 mkdir_all(&agents_dir)?;
767 if !ir.context_binding.absent {
768 mkdir_all(&wren_dir)?;
769 }
770 let mut component_settings: Vec<(String, serde_json::Value)> =
774 Vec::with_capacity(ir.components.len());
775
776 for node in &ir.components {
777 let include_dashboard_save_tool = purpose == Some(NativePurpose::Analysis)
778 && native_mcp.is_some()
779 && (is_native_dashboard_component(node) || is_native_persisted_answer_component(node));
780 let include_persist_answer_tool = purpose == Some(NativePurpose::Analysis)
781 && native_mcp.is_some()
782 && is_native_persisted_answer_component(node);
783 let include_native_terminal_presentation = matches!(
784 purpose,
785 Some(NativePurpose::Analysis | NativePurpose::ContextEnrichment)
786 );
787 let mut rendered_node;
792 let node = if include_native_terminal_presentation {
793 rendered_node = node.clone();
794 rendered_node.prompt_fragment = match purpose {
795 Some(NativePurpose::Analysis) => {
796 native_analysis_prompt_fragment(&node.prompt_fragment)
797 }
798 Some(NativePurpose::ContextEnrichment) => {
799 native_context_enrichment_prompt_fragment(&node.prompt_fragment)
800 }
801 _ => unreachable!("native terminal presentation requires a native purpose"),
802 };
803 &rendered_node
804 } else {
805 node
806 };
807 let report = report_for(&node.id);
808
809 if let Some(host) = native_host
815 .as_ref()
816 .filter(|host| host.tool(&node.id).is_some())
817 {
818 write_file(
819 &agents_dir.join(format!("{}.md", node.verb)),
820 &host.claude_wrapper(node),
821 )?;
822 let mut settings = if should_split_per_step_tier(node) || should_isolate(node) {
825 build_split_settings(
826 node,
827 report,
828 render_flavor,
829 tool_map,
830 include_session_dashboard_save_tool,
831 include_session_persist_answer_tool,
832 )
833 } else {
834 build_settings(
835 node,
836 report,
837 render_flavor,
838 tool_map,
839 false,
840 include_session_dashboard_save_tool,
841 include_session_persist_answer_tool,
842 )
843 };
844 settings["permissions"]["allow"]
845 .as_array_mut()
846 .expect("settings allow")
847 .push(serde_json::json!(format!(
848 "mcp__genbi_session__{}",
849 host.tool(&node.id).expect("root")
850 )));
851 component_settings.push((node.verb.clone(), settings));
852 } else if should_isolate(node) {
853 write_file(
854 &agents_dir.join(format!("{}.md", node.verb)),
855 &build_isolating_parent_markdown(node, report, render_flavor, models)?,
856 )?;
857 write_file(
858 &agents_dir.join(format!("{}.md", isolated_agent_name(&node.verb))),
859 &build_isolated_child_markdown(
860 node,
861 report,
862 render_flavor,
863 models,
864 context,
865 tool_map,
866 include_setup_recovery_instructions,
867 include_dashboard_save_tool,
868 )?,
869 )?;
870 component_settings.push((
873 node.verb.clone(),
874 build_split_settings(
875 node,
876 report,
877 render_flavor,
878 tool_map,
879 include_session_dashboard_save_tool,
880 include_session_persist_answer_tool,
881 ),
882 ));
883 } else if should_split_per_step_tier(node) {
884 let mut driver = build_driver_markdown(
885 node,
886 report,
887 render_flavor,
888 models,
889 context,
890 include_dashboard_save_tool,
891 include_persist_answer_tool,
892 include_native_terminal_presentation,
893 );
894 if include_persist_answer_tool {
895 driver.push('\n');
896 driver.push_str(native_answer_persistence_instructions());
897 }
898 if include_dashboard_save_tool {
899 driver.push('\n');
900 driver.push_str(native_dashboard_save_instructions());
901 }
902 if include_native_terminal_presentation {
903 driver.push('\n');
904 driver.push_str(match purpose {
905 Some(NativePurpose::Analysis) => {
906 native_analysis_terminal_presentation_instructions()
907 }
908 Some(NativePurpose::ContextEnrichment) => {
909 native_context_enrichment_terminal_presentation_instructions()
910 }
911 _ => unreachable!("native terminal presentation requires a native purpose"),
912 });
913 }
914 if purpose == Some(NativePurpose::Setup) {
915 driver.push('\n');
916 driver.push_str(&setup_bootstrap_authority_instructions());
917 }
918 write_file(&agents_dir.join(format!("{}.md", node.verb)), &driver)?;
919 for call in &node.llm_calls {
920 let mut subagent = build_subagent_markdown(node, call, models, context, tool_map);
921 if purpose == Some(NativePurpose::Setup) {
922 subagent.push('\n');
923 subagent.push_str(&setup_bootstrap_authority_instructions());
924 }
925 write_file(
926 &agents_dir.join(format!("{}.md", subagent_name(&node.verb, call))),
927 &subagent,
928 )?;
929 }
930 component_settings.push((
931 node.verb.clone(),
932 build_split_settings(
933 node,
934 report,
935 render_flavor,
936 tool_map,
937 include_session_dashboard_save_tool,
938 include_session_persist_answer_tool,
939 ),
940 ));
941 } else {
942 let mut agent_markdown = build_agent_markdown(
943 node,
944 report,
945 render_flavor,
946 models,
947 context,
948 tool_map,
949 include_setup_recovery_instructions,
950 include_dashboard_save_tool,
951 )?;
952 if include_dashboard_save_tool {
953 agent_markdown.push('\n');
954 agent_markdown.push_str(native_dashboard_save_instructions());
955 }
956 if include_native_terminal_presentation {
957 agent_markdown.push('\n');
958 agent_markdown.push_str(match purpose {
959 Some(NativePurpose::Analysis) => {
960 native_analysis_terminal_presentation_instructions()
961 }
962 Some(NativePurpose::ContextEnrichment) => {
963 native_context_enrichment_terminal_presentation_instructions()
964 }
965 _ => unreachable!("native terminal presentation requires a native purpose"),
966 });
967 }
968 if include_setup_recovery_instructions {
969 agent_markdown.push('\n');
970 agent_markdown.push_str(setup_recovery_instructions());
971 }
972 if purpose == Some(NativePurpose::Setup) {
973 agent_markdown.push('\n');
974 agent_markdown.push_str(&setup_bootstrap_authority_instructions());
975 }
976 write_file(
977 &agents_dir.join(format!("{}.md", node.verb)),
978 &agent_markdown,
979 )?;
980 component_settings.push((
981 node.verb.clone(),
982 build_settings(
983 node,
984 report,
985 render_flavor,
986 tool_map,
987 include_setup_recovery_instructions,
988 include_session_dashboard_save_tool,
989 include_session_persist_answer_tool,
990 ),
991 ));
992 }
993 }
994
995 let scope_components = ir
1000 .components
1001 .iter()
1002 .map(|node| (node, report_for(&node.id)))
1003 .collect::<Vec<_>>();
1004 write_json(
1005 &claude_dir.join("settings.json"),
1006 &native_setup_settings(
1007 merge_scope_settings(&component_settings, !ir.context_binding.absent),
1008 purpose,
1009 native_scope.as_ref(),
1010 )?,
1011 )?;
1012 if !ir.context_binding.absent {
1013 write_json(&wren_dir.join("config.json"), &wren_config())?;
1014 }
1015 let scope_prompt = build_scope_prompt(
1016 ir,
1017 &scope_components,
1018 render_flavor,
1019 scope_denies_destructive_bash(&component_settings),
1020 );
1021 write_file(
1022 &claude_dir.join("CLAUDE.md"),
1023 &match interactive.as_ref() {
1024 Some(output) => format!("{}\n{scope_prompt}", output.marker()),
1025 None => scope_prompt,
1026 },
1027 )?;
1028
1029 let run = match interactive.as_ref() {
1033 Some(output) => format!(
1034 "{}\n{}",
1035 output.marker(),
1036 build_interactive_run_md(
1037 ir,
1038 purpose,
1039 native_scope
1040 .as_ref()
1041 .and_then(|scope| scope.entry.pinned_verb())
1042 )
1043 ),
1044 None => build_profile_run_md(&ir.profile, &scope_components, render_flavor, models)?,
1045 };
1046 write_file(&out_dir.join("RUN.md"), &run)?;
1047
1048 if let (Some(_), Some(descriptor)) = (&interactive, native_mcp.as_ref()) {
1049 write_file(
1050 &out_dir.join(".mcp.json"),
1051 &descriptor.claude_discovery_config()?,
1052 )?;
1053 }
1056
1057 let capability_report = serde_json::json!({
1058 "target": target_id,
1059 "components": ir.components.iter().map(|node| {
1060 let mut entry = serde_json::json!({
1061 "id": node.id,
1062 "capabilities": report_for(&node.id),
1063 });
1064 let granted: serde_json::Map<String, serde_json::Value> = node
1068 .required_capabilities
1069 .iter()
1070 .filter_map(|c| tool_map.get(c.as_str()).map(|b| (c, b)))
1071 .map(|(capability, binding)| {
1072 (
1073 capability.clone(),
1074 serde_json::json!({ "names": binding.names, "source": binding.source }),
1075 )
1076 })
1077 .collect();
1078 if !granted.is_empty() {
1079 entry["tool_bindings"] = serde_json::Value::Object(granted);
1080 }
1081 if should_isolate(node) {
1085 let collapsed = models.collapsed_model(&node.llm_calls).ok();
1086 entry["isolation"] = serde_json::json!({
1087 "realized_via": "single-child-subagent",
1088 "child_agent": isolated_agent_name(&node.verb),
1089 "per_step_tier": if should_split_per_step_tier(node) {
1090 serde_json::json!({
1091 "outcome": "degrade",
1092 "reason": "the whole component runs in one child, so its steps share one model",
1093 "collapsed_to": collapsed,
1094 })
1095 } else {
1096 serde_json::json!({ "outcome": "not-requested" })
1097 },
1098 });
1099 }
1100 entry
1101 }).collect::<Vec<_>>(),
1102 });
1103 write_json(&out_dir.join("capability-report.json"), &capability_report)?;
1104
1105 for node in &ir.components {
1106 print_resolution_summary(
1107 &format!("{target_id} (component '{}')", node.id),
1108 report_for(&node.id),
1109 );
1110 }
1111
1112 if let Some(output) = interactive {
1113 output.write_ownership()?;
1114 output.write_launch_spec()?;
1115 }
1116 Ok(())
1117}