use super::gate::{resolve_render_gate, GateKind, RenderGate};
use super::support::{
find_guardrail, is_assertion, is_mutation, tier_collapse_comment, DEFAULT_ARTIFACT_SCOPE,
};
use super::types::RenderFlavor;
use crate::error::DispatchError;
use crate::ir::{ComponentNode, TriggerKind};
use crate::models::ModelConfig;
use crate::resolve::ResolutionReport;
pub(super) fn run_command_block(node: &ComponentNode, gate: &RenderGate) -> Vec<String> {
let verb = &node.verb;
if is_mutation(node) {
if node.effect.outcome.target.as_deref() == Some("context") {
return vec![
"```sh".to_string(),
"# gated two-phase lifecycle: dry-run -> context-write gate -> human approval -> apply"
.to_string(),
"# 1. dry-run: propose the edit and capture its diff envelope (does NOT apply anything)"
.to_string(),
format!(
"claude -p \"<propose the edit>\" --agent {verb} --output-format json > diff.json"
),
"# 2. context-write gate: the proposed path must resolve inside the component's"
.to_string(),
"# declared context_write_authz scope — a path authorization check, not a blast-radius"
.to_string(),
"# computation".to_string(),
"# 3. human approval (interactive mode only — headless has no human in the loop)"
.to_string(),
"# 4. apply the approved change; a git checkpoint is taken first so it can be rolled back"
.to_string(),
"```".to_string(),
];
}
return vec![
"```sh".to_string(),
"# gated two-phase lifecycle: dry-run -> blast-radius gate -> human approval -> apply"
.to_string(),
"# 1. dry-run: propose the edit and capture its diff envelope (does NOT apply anything)"
.to_string(),
format!(
"claude -p \"<propose the edit>\" --agent {verb} --output-format json > diff.json"
),
"# 2. compute the downstream blast radius of the proposed change".to_string(),
format!(
"warble blast-radius <project> --node {} --diff diff.json",
node.id
),
"# 3. human approval (interactive mode only — headless has no human in the loop)"
.to_string(),
"# 4. apply the approved change; a git checkpoint is taken first so it can be rolled back"
.to_string(),
"```".to_string(),
];
}
if is_assertion(node) {
let mut lines = vec!["```sh".to_string()];
if node.trigger.kind == TriggerKind::Scheduled {
lines.push(
"# register with the runtime scheduler (e.g. cron / launchd / CI) to run on the \
declared cadence; each tick:"
.to_string(),
);
}
lines.push(
"# 1. run the assertion (read-only) and capture its verdict envelope".to_string(),
);
lines.push(format!(
"claude -p \"<check freshness>\" --agent {verb} --output-format json > verdict.json"
));
lines.push("# 2. render the verdict's status block deterministically".to_string());
lines.push("warble render verdict.json --out status.html".to_string());
lines.push("```".to_string());
return lines;
}
if gate.kind == GateKind::Realize && gate.flavor == Some(RenderFlavor::Programmatic) {
vec![
"```sh".to_string(),
"# 1. run the agent (read-only) and capture its render envelope".to_string(),
format!(
"claude -p \"<data question>\" --agent {verb} --output-format json > result.json"
),
"# 2. render the captured envelope to a dashboard deterministically".to_string(),
"warble render result.json --out dashboard.html".to_string(),
"```".to_string(),
]
} else {
vec![
"```sh".to_string(),
format!("claude -p \"<data question>\" --agent {verb}"),
"```".to_string(),
]
}
}
pub(super) fn trigger_note(node: &ComponentNode) -> String {
match node.trigger.kind {
TriggerKind::Scheduled => format!(
"Trigger: `{}` — a resident monitor; register it with the runtime's scheduler (local \
cron / launchd / CI schedule) to run on the cadence. The schedule mechanism is BORROWED from the \
runtime (capability `scheduler`, realize-via), never owned by Warble.",
node.trigger.kind.as_str()
),
_ => format!(
"Trigger: `{}` (single headless invocation, no scheduling/event wiring in this POC).",
node.trigger.kind.as_str()
),
}
}
pub(super) fn assertion_run_notes(node: &ComponentNode) -> Vec<String> {
if !is_assertion(node) {
return vec![];
}
let mut notes = vec![
"Outcome: `assertion` — the agent stays read-only and emits a `{ blocks, verdict, emitted }` \
verdict envelope; the core fresh/stale decision is deterministic SQL (`max(timestamp)` vs cadence), \
not an LLM call."
.to_string(),
];
if let Some(emits) = &node.effect.outcome.emits {
if !emits.is_empty() {
let actions = if node.borrowed_actions.is_empty() {
"a runtime notify channel".to_string()
} else {
node.borrowed_actions.join(", ")
};
notes.push(format!(
"On breach it emits [{}]; the runtime routes those to borrowed on-breach actions \
({actions}) over the `notify_channel` (realize-via, MCP). Warble declares the wiring; the transport \
is borrowed.",
emits.join(", ")
));
}
}
notes
}
pub(super) fn mutation_run_notes(node: &ComponentNode) -> Vec<String> {
if !is_mutation(node) {
return vec![];
}
let gate_note = if node.effect.outcome.target.as_deref() == Some("context") {
let context_guardrail = find_guardrail(&node.guardrails, "context_write_authz");
let scope = context_guardrail
.and_then(|g| g.scope.as_deref())
.unwrap_or(DEFAULT_ARTIFACT_SCOPE);
format!(
"Outcome: `mutation` (target `context`) — a gated two-phase lifecycle: the agent \
proposes a DIFF (dry-run, never applies), a scoped `context_write_authz` gate (scope '{scope}') \
authorizes the write path, then the apply is gated on explicit human approval; headless mode has \
no human in the loop and cannot complete this lifecycle."
)
} else {
"Outcome: `mutation` — a gated two-phase lifecycle: the agent proposes a DIFF (dry-run, \
never applies), a `warble blast-radius` gate computes the downstream impact, then the apply is \
gated on explicit human approval; headless mode has no human in the loop and cannot complete this \
lifecycle."
.to_string()
};
vec![
gate_note,
"Apply + rollback are BORROWED: a version-control (git) checkpoint is taken before applying \
so the change can be rolled back; Warble does not own or reimplement git."
.to_string(),
]
}
pub(super) fn render_run_notes(verb: &str, gate: &RenderGate) -> Vec<String> {
match gate.kind {
GateKind::Realize if gate.flavor == Some(RenderFlavor::Programmatic) => vec![format!(
"Render output: `{verb}` stays fully read-only and emits a `{{ blocks, summary }}` \
render envelope as its final message; `warble render` turns that into `dashboard.html` \
deterministically (no LLM in the render step)."
)],
GateKind::Realize => vec![format!(
"Render output: the agent writes `dashboard.html` into the artifact-write scope \
directory (`{}`) (prompt render flavor).",
gate.scope
.clone()
.unwrap_or_else(|| DEFAULT_ARTIFACT_SCOPE.to_string())
)],
GateKind::Degrade => vec![
"Render output: this target degrades the render contract to a markdown table + prose \
summary (no file is written)."
.to_string(),
],
GateKind::None => vec![],
}
}
pub(super) fn build_run_md(
node: &ComponentNode,
report: &ResolutionReport,
flavor: RenderFlavor,
models: &ModelConfig,
) -> Result<String, DispatchError> {
let model = models.collapsed_model(&node.llm_calls)?;
let collapse = tier_collapse_comment(&node.llm_calls, model);
let gate = resolve_render_gate(node, report, flavor);
let mut notes: Vec<String> = vec![
format!("Bound wren project: `{}`", node.context_binding.project),
trigger_note(node),
];
if collapse.is_some() {
notes.push(
"Note: this component's llm_calls span more than one tier; the emitted agent uses a \
single collapsed driver model (see the comment in the agent markdown file)."
.to_string(),
);
}
notes.extend(render_run_notes(&node.verb, &gate));
notes.extend(assertion_run_notes(node));
notes.extend(mutation_run_notes(node));
let mut parts: Vec<String> = vec![
format!("# Running `{}`", node.verb),
String::new(),
"Run from this directory (so `.claude/` and `.wren/` are picked up):".to_string(),
String::new(),
];
parts.extend(run_command_block(node, &gate));
parts.push(String::new());
parts.extend(notes.iter().map(|n| format!("- {n}")));
parts.push(String::new());
Ok(parts.join("\n"))
}