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 native targets this crate folds into the `warble` binary: the two claude-code modes
74/// (engine × mode), the Codex TUI, and the pi session.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum TargetId {
77    Headless,
78    Interactive,
79    CodexInteractive,
80    PiInteractive,
81}
82
83impl TargetId {
84    pub fn as_str(&self) -> &'static str {
85        match self {
86            TargetId::Headless => "claude-code:headless",
87            TargetId::Interactive => "claude-code:interactive",
88            TargetId::CodexInteractive => "codex:interactive",
89            TargetId::PiInteractive => "pi:interactive",
90        }
91    }
92
93    /// The mode half of `engine × mode`, which is what a provider fragment's optional `mode:`
94    /// field is matched against.
95    pub fn mode(&self) -> &'static str {
96        match self {
97            TargetId::Headless => "headless",
98            TargetId::Interactive => "interactive",
99            // Named for symmetry with the other interactive target. Nothing consults it today:
100            // `codex:interactive` rejects `--provider` outright, since it realizes no capability
101            // of its own and a fragment would silently do nothing.
102            TargetId::CodexInteractive => "interactive",
103            // Same symmetry as Codex: `pi:interactive` rejects `--provider` outright.
104            TargetId::PiInteractive => "interactive",
105        }
106    }
107
108    pub fn parse(value: &str) -> Option<TargetId> {
109        match value {
110            "claude-code:headless" => Some(TargetId::Headless),
111            "claude-code:interactive" => Some(TargetId::Interactive),
112            "codex:interactive" => Some(TargetId::CodexInteractive),
113            "pi:interactive" => Some(TargetId::PiInteractive),
114            _ => None,
115        }
116    }
117
118    pub fn profile(&self) -> CapabilityProfile {
119        match self {
120            TargetId::Headless => headless_profile(),
121            TargetId::Interactive => interactive_profile(),
122            TargetId::CodexInteractive => codex_interactive_profile(),
123            TargetId::PiInteractive => pi_interactive_profile(),
124        }
125    }
126}
127
128/// claude-code target's default mode when the caller doesn't specify one.
129pub const DEFAULT_TARGET: TargetId = TargetId::Headless;
130
131pub fn is_known_target(value: &str) -> bool {
132    TargetId::parse(value).is_some()
133}
134
135pub fn known_target_names() -> [&'static str; 4] {
136    [
137        TargetId::Headless.as_str(),
138        TargetId::Interactive.as_str(),
139        TargetId::CodexInteractive.as_str(),
140        TargetId::PiInteractive.as_str(),
141    ]
142}
143
144/// A native pi session has one model and, by this target's launch contract, no tools except the
145/// host-owned MCP server. Everything analytical is therefore realized through that server; the
146/// mutation, approval and repository-reading capabilities the other native TUIs borrow from
147/// their vendor are absent here and fail loudly. Divergent step tiers degrade to the single
148/// session model unless a host plan (`--native-host`) runs the steps itself.
149fn pi_interactive_profile() -> CapabilityProfile {
150    use CapabilityOutcome::*;
151    use Criticality::*;
152    use ProvidedBy::{Runtime, Warble};
153    profile([
154        (
155            "component_invocation",
156            entry(
157                Fail,
158                None,
159                ProvidedBy::None,
160                Required,
161                Some("pi interactive emits discovery artifacts and has no invocation runtime"),
162            ),
163        ),
164        ("sql_execution:read_only", entry(RealizeVia, Some("native-session-mcp"), Runtime, Required, Some("the host MCP server's query tool; pi has no shell"))),
165        ("genbi_build", entry(RealizeVia, Some("native-session-mcp"), Runtime, Required, Some("the host MCP server's dashboard save tool"))),
166        ("semantic_introspection", entry(Fail, None, ProvidedBy::None, Required, Some("a pi session has no repository read and the host MCP server exposes no introspection tool"))),
167        ("raw_material_read", entry(Fail, None, ProvidedBy::None, Required, Some("a pi session has no repository read"))),
168        ("source_connect", entry(Fail, None, ProvidedBy::None, Required, Some("setup is not realized on pi:interactive"))),
169        ("llm:strong", entry(Native, None, Runtime, Required, None)),
170        ("llm:cheap", entry(Native, None, Runtime, Required, None)),
171        ("llm:per_step_tier", entry(Degrade, Some("single-session-model"), Runtime, Required, Some("every step runs on the one session model; a host plan (--native-host) restores exact tiers by running the steps itself"))),
172        ("context_write_authz", entry(Fail, None, ProvidedBy::None, SafetyCritical, Some("pi has no approval channel"))),
173        ("context_validate", entry(Fail, None, ProvidedBy::None, Required, Some("context enrichment is not realized on pi:interactive"))),
174        ("context_build", entry(Fail, None, ProvidedBy::None, Required, Some("context enrichment is not realized on pi:interactive"))),
175        ("version_control", entry(Fail, None, ProvidedBy::None, Required, Some("a pi session has no shell"))),
176        ("human_approval", entry(Fail, None, ProvidedBy::None, SafetyCritical, Some("pi has no per-tool approval"))),
177        ("enrichment_apply:deterministic", entry(Fail, None, ProvidedBy::None, SafetyCritical, Some("no human-approval gate on pi:interactive"))),
178        ("render_contract", entry(Degrade, Some("terminal-markdown"), Runtime, BestEffort, None)),
179        ("artifact_write", entry(RealizeVia, Some("session-scoped-artifact-api"), Runtime, SafetyCritical, Some("the host owns the artifact API and persistence"))),
180        ("blast_radius", entry(Fail, None, Warble, SafetyCritical, Some("requires fine_grained_binding"))),
181    ])
182}
183
184fn entry(
185    outcome: CapabilityOutcome,
186    via: Option<&'static str>,
187    provided_by: ProvidedBy,
188    criticality: Criticality,
189    note: Option<&'static str>,
190) -> CapabilityEntry {
191    CapabilityEntry {
192        outcome,
193        via: via.map(Cow::Borrowed),
194        provided_by,
195        criticality,
196        note: note.map(Cow::Borrowed),
197    }
198}
199
200fn headless_profile() -> CapabilityProfile {
201    use CapabilityOutcome::*;
202    use Criticality::*;
203    use ProvidedBy::{Runtime, Warble};
204    profile([
205        (
206            "component_invocation",
207            entry(
208                Fail,
209                None,
210                ProvidedBy::None,
211                Required,
212                Some("no trusted component-invocation handler is installed for this file target"),
213            ),
214        ),
215        (
216            "sql_execution:read_only",
217            entry(Native, Some("bash-wren"), Runtime, Required, None),
218        ),
219        (
220            "genbi_build",
221            entry(Native, Some("bash-wren"), Runtime, Required, None),
222        ),
223        (
224            "semantic_introspection",
225            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
226        ),
227        // +Constitutive: reading the semantic model's structure to propose a context edit (models/
228        // metrics/knowledge) — realized the same way as semantic_introspection, via the `wren` CLI.
229        (
230            "schema_introspection",
231            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
232        ),
233        ("llm:strong", entry(Native, None, Runtime, Required, None)),
234        ("llm:cheap", entry(Native, None, Runtime, Required, None)),
235        (
236            "llm:per_step_tier",
237            entry(RealizeVia, Some("subagents"), Runtime, Required, None),
238        ),
239        // Per-step PROVIDER routing (cloud+local in one run) — distinct from per_step_tier, which is
240        // same-provider model selection. The whole-session `claude` process can't switch provider
241        // mid-run, so Warble realizes the LOCAL step two ways (--hybrid-realization): `bash-script`
242        // (a Bash-run local-inference script) or `mcp-server` (a `.mcp.json` registering `warble
243        // mcp-serve`, so the local call is an MCP tool — no bash widening). Cloud steps stay the
244        // driver's own `wren` work either way.
245        (
246            "llm:per_step_provider",
247            entry(
248                RealizeVia,
249                Some("bash-script|mcp-server"),
250                Warble,
251                Required,
252                Some("local step via a bash-script script or an mcp-server (warble mcp-serve); cloud steps are the driver's own wren work"),
253            ),
254        ),
255        (
256            "render_contract",
257            entry(RealizeVia, Some("html-file"), Runtime, BestEffort, None),
258        ),
259        (
260            "structured_output_capture",
261            entry(Native, Some("stream-json"), Runtime, Required, None),
262        ),
263        (
264            "human_approval",
265            entry(
266                Fail,
267                None,
268                ProvidedBy::None,
269                SafetyCritical,
270                Some("no human in the loop in headless mode"),
271            ),
272        ),
273        (
274            "write_authz",
275            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
276        ),
277        // +Constitutive: the path-scoped authorization gate for a `context`-target mutation (models/
278        // metrics/knowledge), distinct from `write_authz` (data writes) and `artifact_write` (render
279        // writes) — scopes must never cross. Realized via the same filesystem, scoped to a path.
280        (
281            "context_write_authz",
282            entry(RealizeVia, Some("scoped-fs"), Runtime, SafetyCritical, None),
283        ),
284        (
285            "artifact_write",
286            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
287        ),
288        // A component may need its working-out kept out of the caller's context — the delegated
289        // steps, the queries, the repairs. Realized here as a subagent: the child holds the whole
290        // component, the caller sees one delegation and one result. A target with no child-context
291        // mechanism must loud-fail rather than run the work in the open.
292        (
293            CONTEXT_ISOLATION_CAPABILITY,
294            entry(RealizeVia, Some("subagent"), Runtime, Required, None),
295        ),
296        // +Assertive borrows the scheduling / event / notify transports from the runtime (OS cron,
297        // pub/sub, MCP) — the IR names the capability + criticality only; the mechanism (cron / slack)
298        // is legalized here, never in the IR (capability-model §6/§7). A target with no such mechanism
299        // wired keeps these `fail` (loud, never silent).
300        (
301            "scheduler",
302            entry(RealizeVia, Some("os-cron"), Runtime, Required, None),
303        ),
304        (
305            "event_bus",
306            entry(RealizeVia, Some("pub-sub"), Runtime, Required, None),
307        ),
308        (
309            "notify_channel",
310            entry(RealizeVia, Some("mcp-notify"), Runtime, Required, None),
311        ),
312        (
313            "blast_radius",
314            entry(
315                Fail,
316                None,
317                Warble,
318                SafetyCritical,
319                Some("requires fine_grained_binding"),
320            ),
321        ),
322        // +Mutating borrows version control (git) from the runtime as the apply-time checkpoint a
323        // rollback restores from. Warble declares the requirement; git itself is never owned here.
324        (
325            "version_control",
326            entry(RealizeVia, Some("git"), Runtime, Required, None),
327        ),
328    ])
329}
330
331fn interactive_profile() -> CapabilityProfile {
332    use CapabilityOutcome::*;
333    use Criticality::*;
334    use ProvidedBy::{Runtime, Warble};
335    profile([
336        (
337            "component_invocation",
338            entry(
339                Fail,
340                None,
341                ProvidedBy::None,
342                Required,
343                Some("no trusted component-invocation handler is installed for this file target"),
344            ),
345        ),
346        (
347            "sql_execution:read_only",
348            entry(Native, Some("bash-wren"), Runtime, Required, None),
349        ),
350        (
351            "genbi_build",
352            entry(Native, Some("bash-wren"), Runtime, Required, None),
353        ),
354        (
355            "semantic_introspection",
356            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
357        ),
358        (
359            "source_connect",
360            entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None),
361        ),
362        // +Constitutive: see headless_profile — same mechanism, not a differentiator across modes.
363        (
364            "schema_introspection",
365            entry(RealizeVia, Some("bash-wren"), Runtime, Required, None),
366        ),
367        ("raw_material_read", entry(Native, Some("Read"), Runtime, Required, Some("Claude Code's cwd-scoped Read tool; prompts forbid credentials and raw excerpts"))),
368        ("context_validate", entry(RealizeVia, Some("Bash(wren:*)"), Runtime, Required, None)),
369        ("context_build", entry(RealizeVia, Some("Bash(wren:*)"), Runtime, Required, None)),
370        ("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"))),
371        ("llm:strong", entry(Native, None, Runtime, Required, None)),
372        ("llm:cheap", entry(Native, None, Runtime, Required, None)),
373        (
374            "llm:per_step_tier",
375            entry(RealizeVia, Some("subagents"), Runtime, Required, None),
376        ),
377        // See headless_profile: per-step provider routing (hybrid) is realized via an emitted
378        // local-inference script (bash-script); same in interactive mode.
379        (
380            "llm:per_step_provider",
381            entry(
382                RealizeVia,
383                Some("bash-script|mcp-server"),
384                Warble,
385                Required,
386                Some("local step via an emitted local-inference script (Bash); cloud steps stay native subagents"),
387            ),
388        ),
389        (
390            "render_contract",
391            entry(
392                Degrade,
393                Some("terminal-markdown"),
394                Runtime,
395                BestEffort,
396                None,
397            ),
398        ),
399        (
400            "structured_output_capture",
401            entry(
402                Degrade,
403                None,
404                ProvidedBy::None,
405                BestEffort,
406                Some("not captured in the interactive TUI"),
407            ),
408        ),
409        (
410            "human_approval",
411            entry(Native, None, Runtime, SafetyCritical, None),
412        ),
413        (
414            "write_authz",
415            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
416        ),
417        // +Constitutive: see headless_profile — same path-scoped gate, not a differentiator across
418        // modes.
419        (
420            "context_write_authz",
421            entry(RealizeVia, Some("scoped-fs"), Runtime, SafetyCritical, None),
422        ),
423        (
424            "artifact_write",
425            entry(RealizeVia, Some("fs"), Runtime, SafetyCritical, None),
426        ),
427        // A component may need its working-out kept out of the caller's context — the delegated
428        // steps, the queries, the repairs. Realized here as a subagent: the child holds the whole
429        // component, the caller sees one delegation and one result. A target with no child-context
430        // mechanism must loud-fail rather than run the work in the open.
431        (
432            CONTEXT_ISOLATION_CAPABILITY,
433            entry(RealizeVia, Some("subagent"), Runtime, Required, None),
434        ),
435        // Same borrowed transports as headless (+Assertive): scheduler/event/notify are runtime-
436        // supplied on both modes; only render_contract + structured_output_capture + human_approval
437        // differ between the two.
438        (
439            "scheduler",
440            entry(RealizeVia, Some("os-cron"), Runtime, Required, None),
441        ),
442        (
443            "event_bus",
444            entry(RealizeVia, Some("pub-sub"), Runtime, Required, None),
445        ),
446        (
447            "notify_channel",
448            entry(RealizeVia, Some("mcp-notify"), Runtime, Required, None),
449        ),
450        (
451            "blast_radius",
452            entry(
453                Fail,
454                None,
455                Warble,
456                SafetyCritical,
457                Some("requires fine_grained_binding"),
458            ),
459        ),
460        // +Mutating borrows version control (git) from the runtime as the apply-time checkpoint a
461        // rollback restores from. Warble declares the requirement; git itself is never owned here.
462        (
463            "version_control",
464            entry(RealizeVia, Some("git"), Runtime, Required, None),
465        ),
466    ])
467}
468
469/// Codex's repository-scoped TUI target. This is intentionally a discovery/materialization
470/// target, not the existing app-server based `codex:local` runtime.
471fn codex_interactive_profile() -> CapabilityProfile {
472    use CapabilityOutcome::*;
473    use Criticality::*;
474    use ProvidedBy::{Runtime, Warble};
475    profile([
476        (
477            "component_invocation",
478            entry(
479                Fail,
480                None,
481                ProvidedBy::None,
482                Required,
483                Some("codex interactive emits discovery artifacts and has no invocation runtime"),
484            ),
485        ),
486        ("sql_execution:read_only", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
487        ("genbi_build", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
488        ("source_connect", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
489        ("semantic_introspection", entry(RealizeVia, Some("codex-repository-read"), Runtime, Required, None)),
490        ("raw_material_read", entry(RealizeVia, Some("codex-repository-read"), Runtime, Required, Some("repository-scoped native TUI reads only; prompts forbid credentials and raw excerpts"))),
491        ("llm:strong", entry(Native, None, Runtime, Required, None)),
492        ("llm:cheap", entry(Native, None, Runtime, Required, None)),
493        ("llm:per_step_tier", entry(RealizeVia, Some("native-interactive-subagents"), Runtime, Required, None)),
494        ("context_write_authz", entry(RealizeVia, Some("native-interactive-human-approval"), Runtime, SafetyCritical, None)),
495        ("context_validate", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
496        ("context_build", entry(RealizeVia, Some("native-interactive-command"), Runtime, Required, None)),
497        ("version_control", entry(RealizeVia, Some("git"), Runtime, Required, None)),
498        ("human_approval", entry(Native, Some("native-interactive-human"), Runtime, SafetyCritical, None)),
499        ("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"))),
500        ("render_contract", entry(Degrade, Some("terminal-markdown"), Runtime, BestEffort, None)),
501        ("artifact_write", entry(RealizeVia, Some("session-scoped-artifact-api"), Runtime, SafetyCritical, Some("the host owns the artifact API and persistence"))),
502        ("blast_radius", entry(Fail, None, Warble, SafetyCritical, Some("requires fine_grained_binding"))),
503    ])
504}