Skip to main content

warble_claude_code/
targets.rs

1//! Target capability profiles — the declarative side of the capability model (see
2//! [`capability-model.md`][spec-cap]).
3//!
4//! A runtime target is `engine × mode`, never just "claude-code": the same engine in a different
5//! mode is a genuinely different capability set (headless *loses* `human_approval` but *gains*
6//! `structured_output_capture`). This module only declares profiles; `resolve.rs` links them.
7//!
8//! [spec-cap]: https://github.com/Canner/Warble/blob/main/docs/spec/capability-model.md
9
10use serde::{Deserialize, Serialize};
11use std::borrow::Cow;
12use std::collections::HashMap;
13
14/// One of the four resolution outcomes a capability can take on a target.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum CapabilityOutcome {
18    Native,
19    RealizeVia,
20    Degrade,
21    Fail,
22}
23
24/// Who supplies a resolved capability.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum ProvidedBy {
28    Runtime,
29    Warble,
30    None,
31}
32
33/// safety-critical capabilities must never silently degrade — unsupported means the resolution
34/// pass aborts. required/best-effort may degrade with a warning recorded in the report.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum Criticality {
38    SafetyCritical,
39    Required,
40    BestEffort,
41}
42
43/// Keeping a component's intermediate work out of the context of whatever called it. The IR names
44/// the requirement; how a target provides a child context — and whether it can at all — is the
45/// target's business, which is why this is a capability rather than a flag.
46pub(crate) const CONTEXT_ISOLATION_CAPABILITY: &str = "context_isolation";
47
48#[derive(Debug, Clone)]
49pub struct CapabilityEntry {
50    pub outcome: CapabilityOutcome,
51    /// `Cow` so a statically-declared entry and one loaded from a provider fragment at dispatch are
52    /// the same type — a base-only profile and a base-⊕-provider-composed one must be, or they
53    /// cannot be merged. Same reason the profile is keyed by `String`.
54    pub via: Option<Cow<'static, str>>,
55    pub provided_by: ProvidedBy,
56    pub criticality: Criticality,
57    pub note: Option<Cow<'static, str>>,
58}
59
60pub type CapabilityProfile = HashMap<String, CapabilityEntry>;
61
62/// Build a profile from statically-declared entries, owning the keys. The declarations below stay
63/// `&'static str` literals; only the map they land in is owned.
64fn profile(
65    entries: impl IntoIterator<Item = (&'static str, CapabilityEntry)>,
66) -> CapabilityProfile {
67    entries
68        .into_iter()
69        .map(|(k, v)| (k.to_string(), v))
70        .collect()
71}
72
73/// The two claude-code targets: engine × mode.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum TargetId {
76    Headless,
77    Interactive,
78    CodexInteractive,
79}
80
81impl TargetId {
82    pub fn as_str(&self) -> &'static str {
83        match self {
84            TargetId::Headless => "claude-code:headless",
85            TargetId::Interactive => "claude-code:interactive",
86            TargetId::CodexInteractive => "codex:interactive",
87        }
88    }
89
90    /// The mode half of `engine × mode`, which is what a provider fragment's optional `mode:`
91    /// field is matched against.
92    pub fn mode(&self) -> &'static str {
93        match self {
94            TargetId::Headless => "headless",
95            TargetId::Interactive => "interactive",
96            // Named for symmetry with the other interactive target. Nothing consults it today:
97            // `codex:interactive` rejects `--provider` outright, since it realizes no capability
98            // of its own and a fragment would silently do nothing.
99            TargetId::CodexInteractive => "interactive",
100        }
101    }
102
103    pub fn parse(value: &str) -> Option<TargetId> {
104        match value {
105            "claude-code:headless" => Some(TargetId::Headless),
106            "claude-code:interactive" => Some(TargetId::Interactive),
107            "codex:interactive" => Some(TargetId::CodexInteractive),
108            _ => None,
109        }
110    }
111
112    pub fn profile(&self) -> CapabilityProfile {
113        match self {
114            TargetId::Headless => headless_profile(),
115            TargetId::Interactive => interactive_profile(),
116            TargetId::CodexInteractive => codex_interactive_profile(),
117        }
118    }
119}
120
121/// claude-code target's default mode when the caller doesn't specify one.
122pub const DEFAULT_TARGET: TargetId = TargetId::Headless;
123
124pub fn is_known_target(value: &str) -> bool {
125    TargetId::parse(value).is_some()
126}
127
128pub fn known_target_names() -> [&'static str; 3] {
129    [
130        TargetId::Headless.as_str(),
131        TargetId::Interactive.as_str(),
132        TargetId::CodexInteractive.as_str(),
133    ]
134}
135
136fn entry(
137    outcome: CapabilityOutcome,
138    via: Option<&'static str>,
139    provided_by: ProvidedBy,
140    criticality: Criticality,
141    note: Option<&'static str>,
142) -> CapabilityEntry {
143    CapabilityEntry {
144        outcome,
145        via: via.map(Cow::Borrowed),
146        provided_by,
147        criticality,
148        note: note.map(Cow::Borrowed),
149    }
150}
151
152fn headless_profile() -> CapabilityProfile {
153    use CapabilityOutcome::*;
154    use Criticality::*;
155    use ProvidedBy::{Runtime, Warble};
156    profile([
157        (
158            "component_invocation",
159            entry(
160                Fail,
161                None,
162                ProvidedBy::None,
163                Required,
164                Some("no trusted component-invocation handler is installed for this file target"),
165            ),
166        ),
167        (
168            "sql_execution:read_only",
169            entry(Native, Some("bash-wren"), Runtime, Required, None),
170        ),
171        (
172            "genbi_build",
173            entry(Native, Some("bash-wren"), Runtime, Required, None),
174        ),
175        (
176            "semantic_introspection",
177            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
178        ),
179        // +Constitutive: reading the semantic model's structure to propose a context edit (models/
180        // metrics/knowledge) — realized the same way as semantic_introspection, via the `wren` CLI.
181        (
182            "schema_introspection",
183            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
184        ),
185        ("llm:strong", entry(Native, None, Runtime, Required, None)),
186        ("llm:cheap", entry(Native, None, Runtime, Required, None)),
187        (
188            "llm:per_step_tier",
189            entry(RealizeVia, Some("subagents"), Runtime, Required, None),
190        ),
191        // Per-step PROVIDER routing (cloud+local in one run) — distinct from per_step_tier, which is
192        // same-provider model selection. The whole-session `claude` process can't switch provider
193        // mid-run, so Warble realizes the LOCAL step two ways (--hybrid-realization): `bash-script`
194        // (a Bash-run local-inference script) or `mcp-server` (a `.mcp.json` registering `warble
195        // mcp-serve`, so the local call is an MCP tool — no bash widening). Cloud steps stay the
196        // driver's own `wren` work either way.
197        (
198            "llm:per_step_provider",
199            entry(
200                RealizeVia,
201                Some("bash-script|mcp-server"),
202                Warble,
203                Required,
204                Some("local step via a bash-script script or an mcp-server (warble mcp-serve); cloud steps are the driver's own wren work"),
205            ),
206        ),
207        (
208            "render_contract",
209            entry(RealizeVia, Some("html-file"), Runtime, BestEffort, None),
210        ),
211        (
212            "structured_output_capture",
213            entry(Native, Some("stream-json"), Runtime, Required, None),
214        ),
215        (
216            "human_approval",
217            entry(
218                Fail,
219                None,
220                ProvidedBy::None,
221                SafetyCritical,
222                Some("no human in the loop in headless mode"),
223            ),
224        ),
225        (
226            "write_authz",
227            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
228        ),
229        // +Constitutive: the path-scoped authorization gate for a `context`-target mutation (models/
230        // metrics/knowledge), distinct from `write_authz` (data writes) and `artifact_write` (render
231        // writes) — scopes must never cross. Realized via the same filesystem, scoped to a path.
232        (
233            "context_write_authz",
234            entry(RealizeVia, Some("scoped-fs"), Runtime, SafetyCritical, None),
235        ),
236        (
237            "artifact_write",
238            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
239        ),
240        // A component may need its working-out kept out of the caller's context — the delegated
241        // steps, the queries, the repairs. Realized here as a subagent: the child holds the whole
242        // component, the caller sees one delegation and one result. A target with no child-context
243        // mechanism must loud-fail rather than run the work in the open.
244        (
245            CONTEXT_ISOLATION_CAPABILITY,
246            entry(RealizeVia, Some("subagent"), Runtime, Required, None),
247        ),
248        // +Assertive borrows the scheduling / event / notify transports from the runtime (OS cron,
249        // pub/sub, MCP) — the IR names the capability + criticality only; the mechanism (cron / slack)
250        // is legalized here, never in the IR (capability-model §6/§7). A target with no such mechanism
251        // wired keeps these `fail` (loud, never silent).
252        (
253            "scheduler",
254            entry(RealizeVia, Some("os-cron"), Runtime, Required, None),
255        ),
256        (
257            "event_bus",
258            entry(RealizeVia, Some("pub-sub"), Runtime, Required, None),
259        ),
260        (
261            "notify_channel",
262            entry(RealizeVia, Some("mcp-notify"), Runtime, Required, None),
263        ),
264        (
265            "blast_radius",
266            entry(
267                Fail,
268                None,
269                Warble,
270                SafetyCritical,
271                Some("requires fine_grained_binding"),
272            ),
273        ),
274        // +Mutating borrows version control (git) from the runtime as the apply-time checkpoint a
275        // rollback restores from. Warble declares the requirement; git itself is never owned here.
276        (
277            "version_control",
278            entry(RealizeVia, Some("git"), Runtime, Required, None),
279        ),
280    ])
281}
282
283fn interactive_profile() -> CapabilityProfile {
284    use CapabilityOutcome::*;
285    use Criticality::*;
286    use ProvidedBy::{Runtime, Warble};
287    profile([
288        (
289            "component_invocation",
290            entry(
291                Fail,
292                None,
293                ProvidedBy::None,
294                Required,
295                Some("no trusted component-invocation handler is installed for this file target"),
296            ),
297        ),
298        (
299            "sql_execution:read_only",
300            entry(Native, Some("bash-wren"), Runtime, Required, None),
301        ),
302        (
303            "genbi_build",
304            entry(Native, Some("bash-wren"), Runtime, Required, None),
305        ),
306        (
307            "semantic_introspection",
308            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
309        ),
310        (
311            "source_connect",
312            entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None),
313        ),
314        // +Constitutive: see headless_profile — same mechanism, not a differentiator across modes.
315        (
316            "schema_introspection",
317            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
318        ),
319        ("raw_material_read", entry(Native, Some("Read"), Runtime, Required, Some("Claude Code's cwd-scoped Read tool; prompts forbid credentials and raw excerpts"))),
320        ("context_validate", entry(RealizeVia, Some("Bash(wren:*)"), Runtime, Required, None)),
321        ("context_build", entry(RealizeVia, Some("Bash(wren:*)"), Runtime, Required, None)),
322        ("enrichment_apply:deterministic", entry(RealizeVia, Some("native-interactive-approved-tool"), Runtime, SafetyCritical, Some("only after the native human-approval gate; Warble does not run it"))),
323        ("llm:strong", entry(Native, None, Runtime, Required, None)),
324        ("llm:cheap", entry(Native, None, Runtime, Required, None)),
325        (
326            "llm:per_step_tier",
327            entry(RealizeVia, Some("subagents"), Runtime, Required, None),
328        ),
329        // See headless_profile: per-step provider routing (hybrid) is realized via an emitted
330        // local-inference script (bash-script); same in interactive mode.
331        (
332            "llm:per_step_provider",
333            entry(
334                RealizeVia,
335                Some("bash-script|mcp-server"),
336                Warble,
337                Required,
338                Some("local step via an emitted local-inference script (Bash); cloud steps stay native subagents"),
339            ),
340        ),
341        (
342            "render_contract",
343            entry(
344                Degrade,
345                Some("terminal-markdown"),
346                Runtime,
347                BestEffort,
348                None,
349            ),
350        ),
351        (
352            "structured_output_capture",
353            entry(
354                Degrade,
355                None,
356                ProvidedBy::None,
357                BestEffort,
358                Some("not captured in the interactive TUI"),
359            ),
360        ),
361        (
362            "human_approval",
363            entry(Native, None, Runtime, SafetyCritical, None),
364        ),
365        (
366            "write_authz",
367            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
368        ),
369        // +Constitutive: see headless_profile — same path-scoped gate, not a differentiator across
370        // modes.
371        (
372            "context_write_authz",
373            entry(RealizeVia, Some("scoped-fs"), Runtime, SafetyCritical, None),
374        ),
375        (
376            "artifact_write",
377            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
378        ),
379        // A component may need its working-out kept out of the caller's context — the delegated
380        // steps, the queries, the repairs. Realized here as a subagent: the child holds the whole
381        // component, the caller sees one delegation and one result. A target with no child-context
382        // mechanism must loud-fail rather than run the work in the open.
383        (
384            CONTEXT_ISOLATION_CAPABILITY,
385            entry(RealizeVia, Some("subagent"), Runtime, Required, None),
386        ),
387        // Same borrowed transports as headless (+Assertive): scheduler/event/notify are runtime-
388        // supplied on both modes; only render_contract + structured_output_capture + human_approval
389        // differ between the two.
390        (
391            "scheduler",
392            entry(RealizeVia, Some("os-cron"), Runtime, Required, None),
393        ),
394        (
395            "event_bus",
396            entry(RealizeVia, Some("pub-sub"), Runtime, Required, None),
397        ),
398        (
399            "notify_channel",
400            entry(RealizeVia, Some("mcp-notify"), Runtime, Required, None),
401        ),
402        (
403            "blast_radius",
404            entry(
405                Fail,
406                None,
407                Warble,
408                SafetyCritical,
409                Some("requires fine_grained_binding"),
410            ),
411        ),
412        // +Mutating borrows version control (git) from the runtime as the apply-time checkpoint a
413        // rollback restores from. Warble declares the requirement; git itself is never owned here.
414        (
415            "version_control",
416            entry(RealizeVia, Some("git"), Runtime, Required, None),
417        ),
418    ])
419}
420
421/// Codex's repository-scoped TUI target. This is intentionally a discovery/materialization
422/// target, not the existing app-server based `codex:local` runtime.
423fn codex_interactive_profile() -> CapabilityProfile {
424    use CapabilityOutcome::*;
425    use Criticality::*;
426    use ProvidedBy::{Runtime, Warble};
427    profile([
428        (
429            "component_invocation",
430            entry(
431                Fail,
432                None,
433                ProvidedBy::None,
434                Required,
435                Some("codex interactive emits discovery artifacts and has no invocation runtime"),
436            ),
437        ),
438        ("sql_execution:read_only", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
439        ("genbi_build", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
440        ("source_connect", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
441        ("semantic_introspection", entry(RealizeVia, Some("codex-repository-read"), Runtime, Required, None)),
442        ("raw_material_read", entry(RealizeVia, Some("codex-repository-read"), Runtime, Required, Some("repository-scoped native TUI reads only; prompts forbid credentials and raw excerpts"))),
443        ("llm:strong", entry(Native, None, Runtime, Required, None)),
444        ("llm:cheap", entry(Native, None, Runtime, Required, None)),
445        ("llm:per_step_tier", entry(RealizeVia, Some("native-interactive-subagents"), Runtime, Required, None)),
446        ("context_write_authz", entry(RealizeVia, Some("native-interactive-human-approval"), Runtime, SafetyCritical, None)),
447        ("context_validate", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
448        ("context_build", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
449        ("version_control", entry(RealizeVia, Some("git"), Runtime, Required, None)),
450        ("human_approval", entry(Native, Some("native-interactive-human"), Runtime, SafetyCritical, None)),
451        ("enrichment_apply:deterministic", entry(RealizeVia, Some("native-interactive-approved-tool"), Runtime, SafetyCritical, Some("only after the native human-approval gate; Warble does not run it"))),
452        ("render_contract", entry(Degrade, Some("terminal-markdown"), Runtime, BestEffort, None)),
453        ("artifact_write", entry(RealizeVia, Some("session-scoped-artifact-api"), Runtime, SafetyCritical, Some("the host owns the artifact API and persistence"))),
454        ("blast_radius", entry(Fail, None, Warble, SafetyCritical, Some("requires fine_grained_binding"))),
455    ])
456}