warble-claude-code 0.18.0

Warble back-end for the Claude Code CLI target: IR → static agent files + reference renderer + capability manifest.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! RUN.md assembly. RUN.md is one profile-level document (`build_profile_run_md` for the file
//! targets, `build_interactive_run_md` for native interactive dispatch) with a section per emitted
//! component agent, built from the per-outcome run-note and run-command builders below.

use super::gate::{resolve_render_gate, GateKind, RenderGate};
use super::isolate::{isolated_agent_name, should_isolate};
use super::split::{should_split_per_step_tier, subagent_name};
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;
    let request = if node.context_binding.absent {
        "<request>"
    } else {
        "<data question>"
    };
    // +Mutating: the gated two-phase lifecycle, shown conceptually — dry-run capture diff, run the
    // `warble blast-radius` gate, wait for human approval (interactive only), then apply. Apply
    // itself and rollback are BORROWED (git) and not shown as a warble subcommand here.
    //
    // +Constitutive (`outcome.target == "context"`) reuses this same arm but phase 2 is a scoped
    // context-write gate (path authorization), not a `warble blast-radius` subcommand — there is no
    // blast-radius computation on this path.
    if is_mutation(node) {
        return vec![
            "This target does not provide a headless apply command. Start a native interactive CLI only after an external, enforceable approval path is available; otherwise `apply_enrichment` loud-fails.".to_string(),
        ];
    }
    // +Assertive: a scheduled monitor emits a read-only verdict envelope; capture it and render the
    // `status` block. The cadence is borrowed from the runtime scheduler (cron / launchd / CI) — the
    // mechanism is named here (a back-end artifact), never in the IR.
    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 \"{request}\" --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 \"{request}\" --agent {verb}"),
            "```".to_string(),
        ]
    }
}

/// The `Trigger:` line for RUN.md. `scheduled` (+Assertive) borrows the runtime's scheduler and
/// says so; `one_shot` keeps the single-invocation note.
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()
        ),
    }
}

/// On-breach emit + notify notes for an assertion outcome (RUN.md). Names the emitted signals and
/// the borrowed notify actions; the transport is borrowed (MCP), the wiring is Warble's.
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
}

/// Two-phase gated lifecycle notes for a mutation outcome (RUN.md). Names the borrowed version-
/// control/rollback mechanism and the gate step; mirrors `assertion_run_notes`. +Constitutive
/// (`outcome.target == "context"`) reuses this same arm but names the scoped `context_write_authz`
/// gate instead of `warble blast-radius`, since there is no blast-radius computation on that path.
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![],
    }
}

/// One component's run section inside the profile-level RUN.md: its own invocation plus the notes
/// that belong to its enum arms. Emitting the whole profile is what makes the sections necessary —
/// a per-component document could put this at the top level, but then only one component could own
/// RUN.md.
fn component_run_section(
    node: &ComponentNode,
    report: &ResolutionReport,
    flavor: RenderFlavor,
    models: &ModelConfig,
    binding_is_shared: bool,
) -> Result<Vec<String>, DispatchError> {
    let gate = resolve_render_gate(node, report, flavor);
    let split = should_split_per_step_tier(node) && !should_isolate(node);

    let mut notes: Vec<String> = Vec::new();
    // Every component of a profile normally resolves to the same bound project, and repeating it in
    // each section reads as if they could differ per invocation. The binding is per component in the
    // IR though, so a profile that really does bind two projects still says so section by section.
    if !binding_is_shared && !node.context_binding.absent {
        notes.push(format!(
            "Bound wren project: `{}`",
            node.context_binding.project
        ));
    }
    notes.push(trigger_note(node));
    if split {
        let subagent_models = node
            .llm_calls
            .iter()
            .map(|call| {
                Ok(format!(
                    "`{}`={}",
                    subagent_name(&node.verb, call),
                    models.require(&call.tier)?
                ))
            })
            .collect::<Result<Vec<_>, DispatchError>>()?
            .join(", ");
        notes.push(format!(
            "Per-step tiers are realized as subagents ({subagent_models}); the driver ({}) only \
routes + marshals between them via the Task tool.",
            models.orchestrator()?
        ));
    } else if !node.llm_calls.is_empty()
        && tier_collapse_comment(&node.llm_calls, models.collapsed_model(&node.llm_calls)?)
            .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![format!("## `{}`", node.verb), String::new()];
    parts.extend(run_command_block(node, &gate));
    parts.push(String::new());
    parts.extend(notes.iter().map(|note| format!("- {note}")));
    parts.push(String::new());
    Ok(parts)
}

/// RUN.md for the file targets, written once for the whole profile.
///
/// RUN.md is a profile-level artifact because the emitted directory is: one `.claude/`, one
/// `.wren/`, one settings file, N component agents. Building it per component would mean N writes
/// to one path, where the last component silently wins and the rest of the profile is undocumented.
pub(super) fn build_profile_run_md(
    profile: &str,
    components: &[(&ComponentNode, &ResolutionReport)],
    flavor: RenderFlavor,
    models: &ModelConfig,
) -> Result<String, DispatchError> {
    let shared_binding = components
        .first()
        .filter(|(node, _)| !node.context_binding.absent)
        .map(|(node, _)| node.context_binding.project.as_str())
        .filter(|project| {
            components
                .iter()
                .all(|(node, _)| node.context_binding.project == *project)
        });

    let mut parts: Vec<String> = vec![
        format!("# Running `{profile}`"),
        String::new(),
        if components
            .iter()
            .all(|(node, _)| node.context_binding.absent)
        {
            "Run each agent from this directory so its `.claude/` files are loaded. No semantic project or data CLI is required.".to_string()
        } else {
            "Run each agent from this directory (so `.claude/` and `.wren/` are picked up)."
                .to_string()
        },
        String::new(),
        match components.len() {
            1 => "This profile emits one component agent.".to_string(),
            n => format!("This profile emits {n} component agents; each is invoked on its own."),
        },
        String::new(),
    ];
    if let Some(project) = shared_binding {
        parts.push(format!("- Bound wren project: `{project}`"));
        parts.push(String::new());
    }
    for (node, report) in components {
        parts.extend(component_run_section(
            node,
            report,
            flavor,
            models,
            shared_binding.is_some(),
        )?);
    }
    Ok(parts.join("\n"))
}

/// RUN.md for native interactive dispatch, written once for the whole profile.
///
/// Interactive dispatch never owns a one-shot/print-mode invocation, and it never selects a
/// component of its own accord: the caller starts the TUI in the canonical output cwd with the
/// launch spec's `argv`, which is empty unless a native purpose declares the profile's entry agent.
/// The emitted component agents are listed as what that one session has available, not as N
/// alternative sessions to start.
pub(super) fn build_interactive_run_md(
    ir: &crate::ir::WarbleIr,
    purpose: Option<crate::interactive::NativePurpose>,
    entry_verb: Option<&str>,
) -> String {
    let entry = entry_verb;
    let mut parts = vec![
        format!("# Running `{}` interactively", ir.profile),
        String::new(),
        "Read `.warble/interactive-launch.json` and start the native Claude Code TUI from its canonical `cwd` with its `argv`."
            .to_string(),
        String::new(),
        "```sh".to_string(),
        match entry {
            Some(agent) => format!("claude --agent {agent}"),
            None => "claude".to_string(),
        },
        "```".to_string(),
        String::new(),
    ];
    match (entry, purpose) {
        (Some(agent), Some(purpose)) => parts.push(format!(
            "This opens a native interactive session on this profile, with `{agent}` — its entry \
agent for the `{}` session purpose — selected. Submit the request inside the TUI; the caller owns \
the PTY, prompt, transcript, and session lifecycle.",
            purpose.as_str()
        )),
        // Scope entry: the caller declared it, so `.claude/CLAUDE.md` is the session entry and the
        // components are delegable from it. This is a different situation from the no-purpose case
        // below even though both omit `--agent`: a native scope document inventories every agent
        // with the description its component authored, which is what the session's driver selects
        // on. `validate_scope_entry` has already refused a profile with fewer than two eligible
        // components, so there is always something to choose between.
        (None, Some(purpose)) => parts.push(format!(
            "This opens a native interactive session on this profile at the scope document \
(`.claude/CLAUDE.md`) for the `{}` session purpose, with no component pinned. Every agent below is \
delegable, and the session's own driver selects the one that owns each request. Submit the request \
inside the TUI; the caller owns the PTY, prompt, transcript, and session lifecycle.",
            purpose.as_str()
        )),
        // No purpose declares an entry at all, so the spec selects none — and this session is NOT
        // running the profile's behavior. Its agents are on disk, but nothing puts one in charge,
        // and a component that never authored a description contributes only its IR shape here,
        // which gives vendor-side delegation nothing to match on. Saying otherwise would claim
        // routing that no emitted artifact provides.
        _ => parts.push(
            "That starts a native interactive session with no agent selected: a plain session in \
this directory, which has this profile's agents on disk but none of them in charge. Do not rely on \
it delegating to them by itself. Select the component whose behavior you want instead:"
                .to_string(),
        ),
    }
    parts.push(String::new());
    // Only the unselected, purposeless session needs the per-agent command list as a remedy. A
    // scope entry is already a coherent session, so listing alternatives would read as an
    // instruction to restart it differently.
    if entry.is_none() && purpose.is_none() {
        parts.push("```sh".to_string());
        for node in &ir.components {
            parts.push(format!("claude --agent {}", node.verb));
        }
        parts.push("```".to_string());
        parts.push(String::new());
        parts.push(
            "The caller owns the PTY, prompt, transcript, and session lifecycle either way."
                .to_string(),
        );
        parts.push(String::new());
    }
    parts.push("Agents emitted by this profile:".to_string());
    parts.push(String::new());
    for node in &ir.components {
        let steps = if should_split_per_step_tier(node) && !should_isolate(node) {
            node.llm_calls
                .iter()
                .map(|call| format!("`{}`", subagent_name(&node.verb, call)))
                .collect::<Vec<_>>()
                .join(", ")
        } else if should_isolate(node) {
            format!("`{}`", isolated_agent_name(&node.verb))
        } else {
            String::new()
        };
        let entry_marker = if entry == Some(node.verb.as_str()) {
            " (entry)"
        } else {
            ""
        };
        parts.push(if steps.is_empty() {
            format!("- `{}`{entry_marker}", node.verb)
        } else {
            format!("- `{}`{entry_marker} — subagents: {steps}", node.verb)
        });
    }
    parts.push(String::new());
    parts.join("\n")
}