Skip to main content

warble_claude_code/
ir.rs

1//! Typed view of the Warble IR (`warble_ir_version: 0.5`) that this back-end consumes.
2//!
3//! Mirrors [`ir-schema.md`][spec-ir] field-for-field. The IR JSON is the language-neutral seam
4//! between the front-end compiler and any back-end: this module depends on the schema doc, not on
5//! the front-end's Rust types. Enum arms cover every schema-valid value; arms this target does not
6//! yet realize are rejected at emit time (a "wall-hit"), not at deserialization.
7//!
8//! [spec-ir]: https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md
9
10use crate::error::DispatchError;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14/// IR version this back-end understands. Copied (not shared via a `core` dependency — dispatchers
15/// never depend on `warble`) from the same source of truth as `docs/spec/ir-schema.md`'s title and
16/// the TS back-end's `SUPPORTED_IR_VERSIONS` in `dispatcher/claude-agent-sdk/src/ir.ts`. The sole
17/// cross-target lockstep owner is `core/tests/ir_version_lockstep_tests.rs`; this target's
18/// `ir_version_tests.rs` covers only its own rejection behavior. An unrecognized
19/// `warble_ir_version` is a loud-fail — see [`validate_ir_version`] — never a silent best-effort
20/// read.
21pub const SUPPORTED_IR_VERSION: &str = "0.5";
22
23/// The one version gate every IR-consuming entry point in this crate (and the `cli` binary, at IR
24/// parse time) must call before doing anything else with `ir`: `emit_claude_code_with_realization`
25/// calls this as its first statement, and `cli::load_ir` calls it right after deserializing, so
26/// every subcommand that reads an `ir.json` — `dispatch` and `manifest` alike — is covered by the
27/// same check instead of each reimplementing (and potentially forgetting) it.
28pub fn validate_ir_version(ir: &WarbleIr) -> Result<(), DispatchError> {
29    if ir.warble_ir_version != SUPPORTED_IR_VERSION {
30        return Err(DispatchError::new(format!(
31            "unsupported warble_ir_version '{}' (this back-end understands: {SUPPORTED_IR_VERSION})",
32            ir.warble_ir_version
33        )));
34    }
35    Ok(())
36}
37
38/// A step's tier is an **open string**, not a fixed enum. Warble ships a standard core vocabulary
39/// (`strong`, `cheap`) that keeps components portable, but a component may name a custom tier; the
40/// dispatch-time model config (see `ModelConfig`) maps each tier name to a concrete model, and a
41/// tier with no mapping is a loud-fail. Kept as `String` so the seam never constrains the vocabulary.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum RealizationKind {
45    Skill,
46    Tool,
47    GatedTool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum ComponentType {
53    Analytical,
54    Assertive,
55    Mutating,
56    Constitutive,
57    Orchestrating,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TriggerKind {
63    OneShot,
64    Scheduled,
65    Event,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum OutcomeKind {
71    None,
72    Assertion,
73    Mutation,
74    Dispatch,
75}
76
77impl RealizationKind {
78    pub fn as_str(&self) -> &'static str {
79        match self {
80            RealizationKind::Skill => "skill",
81            RealizationKind::Tool => "tool",
82            RealizationKind::GatedTool => "gated-tool",
83        }
84    }
85}
86
87impl TriggerKind {
88    pub fn as_str(&self) -> &'static str {
89        match self {
90            TriggerKind::OneShot => "one_shot",
91            TriggerKind::Scheduled => "scheduled",
92            TriggerKind::Event => "event",
93        }
94    }
95}
96
97impl OutcomeKind {
98    pub fn as_str(&self) -> &'static str {
99        match self {
100            OutcomeKind::None => "none",
101            OutcomeKind::Assertion => "assertion",
102            OutcomeKind::Mutation => "mutation",
103            OutcomeKind::Dispatch => "dispatch",
104        }
105    }
106}
107
108impl ComponentType {
109    pub fn as_str(&self) -> &'static str {
110        match self {
111            ComponentType::Analytical => "analytical",
112            ComponentType::Assertive => "assertive",
113            ComponentType::Mutating => "mutating",
114            ComponentType::Constitutive => "constitutive",
115            ComponentType::Orchestrating => "orchestrating",
116        }
117    }
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct ContextBinding {
122    pub project: String,
123    pub binding_mode: String,
124    /// Fine-grained resolved binding (IR v0.3): metrics/dimensions/grains/lineage summary the
125    /// front-end learned from the bound semantic layer. Carried through and tolerated here; the
126    /// Claude Code back-end does not yet consume it (it still drives off the coarse project path).
127    #[serde(default)]
128    pub resolved: Option<serde_json::Value>,
129}
130
131#[derive(Debug, Clone, Deserialize)]
132pub struct IrConfig {
133    #[serde(default)]
134    pub tier_policy: Option<String>,
135}
136
137#[derive(Debug, Clone, Deserialize)]
138pub struct LlmCall {
139    pub name: String,
140    /// Tier name (conventionally `strong`/`cheap`; custom names allowed — see [`RealizationKind`]
141    /// docs and `ModelConfig`). Resolved to a concrete model at dispatch.
142    pub tier: String,
143    #[serde(default)]
144    pub consumes: Vec<String>,
145    #[serde(default)]
146    pub produces: Option<String>,
147    pub prompt: String,
148    /// Whether this step only runs under a runtime condition (e.g. a prior step's outcome).
149    #[serde(default)]
150    pub conditional: bool,
151    /// The closed-vocabulary guard deciding whether a `conditional` step runs (IR v0.3+; see
152    /// [`ir-schema.md`][spec-ir]). This back-end's actual emission still treats `conditional` as an
153    /// opaque flag — the static Claude Code CLI target has no deterministic runtime, so the guard is
154    /// judged emergently by the live CLI agent from the emitted prompt text, not evaluated here.
155    /// `conditional.rs` carries a deterministic evaluator for the same closed vocabulary, but it is
156    /// conformance-only (tested against a fixture shared with the SDK back-end's evaluator, not
157    /// wired into emission). This field is tolerated either way so the seam stays forward-compatible.
158    ///
159    /// [spec-ir]: https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md
160    #[serde(default)]
161    pub when: Option<WhenGuard>,
162}
163
164/// A closed-vocabulary guard on a conditional `llm_call`: `guard` is one of `on_failure` /
165/// `on_flag` / `on_missing`, `target` is the guard-specific argument. See
166/// [`ir-schema.md`][spec-ir].
167///
168/// [spec-ir]: https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md
169#[derive(Debug, Clone, Deserialize)]
170pub struct WhenGuard {
171    pub guard: String,
172    pub target: String,
173}
174
175#[derive(Debug, Clone, Deserialize)]
176pub struct Guardrail {
177    pub name: String,
178    pub locked: bool,
179    #[serde(default)]
180    pub scope: Option<String>,
181    /// Optional numeric or structured threshold the guardrail enforces (shape is guardrail-specific).
182    #[serde(default)]
183    pub threshold: Option<serde_json::Value>,
184}
185
186#[derive(Debug, Clone, Deserialize)]
187pub struct Trigger {
188    pub kind: TriggerKind,
189}
190
191/// A typed render block: a block type plus its field-name → field-type schema. The field-type
192/// strings are echoed into the agent's render contract verbatim; the back-end never interprets
193/// them. `BTreeMap` keeps field order stable (deterministic output).
194#[derive(Debug, Clone, Deserialize)]
195pub struct RenderBlock {
196    #[serde(rename = "type")]
197    pub block_type: String,
198    #[serde(default)]
199    pub fields: BTreeMap<String, String>,
200}
201
202#[derive(Debug, Clone, Deserialize)]
203pub struct Outcome {
204    pub kind: OutcomeKind,
205    /// Free-form verdict classification for `assertion`-kind outcomes.
206    #[serde(default)]
207    pub verdict_type: Option<String>,
208    /// Event/signal names this outcome emits (e.g. for `dispatch`-kind outcomes).
209    #[serde(default)]
210    pub emits: Option<Vec<String>>,
211    /// The mutation/dispatch target, when applicable.
212    #[serde(default)]
213    pub target: Option<String>,
214    /// The kind of change made, for `mutation`-kind outcomes.
215    #[serde(default)]
216    pub change_type: Option<String>,
217    /// Routing scope for `dispatch`-kind outcomes (shape is dispatch-target-specific).
218    #[serde(default)]
219    pub routable_scope: Option<serde_json::Value>,
220}
221
222#[derive(Debug, Clone, Deserialize)]
223pub struct Effect {
224    #[serde(default)]
225    pub render_blocks: Vec<RenderBlock>,
226    pub outcome: Outcome,
227}
228
229#[derive(Debug, Clone, Deserialize)]
230pub struct PreconditionResult {
231    pub status: String,
232    /// Per-predicate evaluation results (IR v0.3): each `{predicate, outcome}`. In v0.2 this was a
233    /// free-form string list; v0.3 makes it structured now that predicates are really evaluated.
234    #[serde(default)]
235    pub checks: Vec<PreconditionCheck>,
236}
237
238/// One evaluated `context_precondition` and its outcome (`pass` in emitted IR — a failing
239/// predicate loud-fails the compile before any IR is emitted).
240#[derive(Debug, Clone, Deserialize)]
241pub struct PreconditionCheck {
242    pub predicate: String,
243    pub outcome: String,
244}
245
246/// A context precondition a component requires to hold before it runs (e.g. `has_metric`).
247#[derive(Debug, Clone, Deserialize)]
248pub struct Precondition {
249    pub predicate: String,
250    #[serde(default)]
251    pub args: Option<serde_json::Value>,
252}
253
254/// A component parameter, either bound at dispatch time (`bind`, with an optional `default`) or
255/// sourced from context (`source`). Exactly one of `bind`/`source` is expected to be present per
256/// the schema, but both are optional here since this is a Deserialize-only view.
257#[derive(Debug, Clone, Deserialize)]
258pub struct Param {
259    pub name: String,
260    #[serde(default)]
261    pub bind: Option<String>,
262    #[serde(default)]
263    pub source: Option<String>,
264    #[serde(default)]
265    pub default: Option<serde_json::Value>,
266}
267
268/// An authored evaluation spec: which eval template to run and which metrics it scores.
269#[derive(Debug, Clone, Deserialize)]
270pub struct EvalSpec {
271    pub template_ref: String,
272    #[serde(default)]
273    pub metrics: Vec<String>,
274}
275
276#[derive(Debug, Clone, Deserialize)]
277pub struct ComponentNode {
278    pub id: String,
279    pub verb: String,
280    #[serde(rename = "type")]
281    pub component_type: ComponentType,
282    pub realization_kind: RealizationKind,
283    pub context_binding: ContextBinding,
284    pub precondition_result: PreconditionResult,
285    pub prompt_fragment: String,
286    pub llm_calls: Vec<LlmCall>,
287    pub guardrails: Vec<Guardrail>,
288    pub trigger: Trigger,
289    #[serde(default)]
290    pub required_capabilities: Vec<String>,
291    #[serde(default)]
292    pub borrowed_actions: Vec<String>,
293    pub eval_ref: String,
294    pub effect: Effect,
295    /// Free-text context capabilities this component requires (e.g. "a wren project ...").
296    #[serde(default)]
297    pub context_requirements: Vec<String>,
298    /// Structured preconditions the bound context must satisfy (e.g. `has_metric`).
299    #[serde(default)]
300    pub context_precondition: Vec<Precondition>,
301    /// Component parameters, either context-sourced or dispatch-bound.
302    #[serde(default)]
303    pub params: Vec<Param>,
304    /// Authored evaluation spec, when the component declares one.
305    #[serde(default)]
306    pub eval: Option<EvalSpec>,
307    /// Optional free-form framing shared by every step of this component (see
308    /// `docs/spec/ir-schema.md`).
309    #[serde(default)]
310    pub brief: Option<String>,
311    /// What this component is for, authored for a reader choosing between components rather than for
312    /// the agent doing the work. Absent when the author wrote none, in which case the back-end
313    /// synthesizes a shape line instead.
314    #[serde(default)]
315    pub description: Option<String>,
316    /// Example requests this component is the right destination for.
317    #[serde(default)]
318    pub examples: Vec<String>,
319}
320
321#[derive(Debug, Clone, Deserialize)]
322pub struct WarbleIr {
323    pub warble_ir_version: String,
324    pub profile: String,
325    pub context_binding: ContextBinding,
326    pub config: IrConfig,
327    pub components: Vec<ComponentNode>,
328}