diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md index b656161..2c9cdb1 100644 --- a/docs/HOST-SUPPORT.md +++ b/docs/HOST-SUPPORT.md @@ -1,9 +1,17 @@ # Host support: Claude Code, Codex, and OpenCode -This is the canonical compatibility reference for the three execution hosts that -agentic-kit can manage. It compares the host itself, Ruflo, agentic-qe (AQE), and +This is the canonical compatibility reference for agentic-kit's three **built-in** +execution hosts. It compares the host itself, Ruflo, agentic-qe (AQE), and RuvNet Brain without treating those independent layers as interchangeable. +Behind an experimental flag, agentic-kit can also admit **external host adapters** +that extend this set with a host not shipped in-tree — see +[External host adapters](PROVIDERS.md#external-host-adapters-experimental) and +[ADR-0029](adr/0029-host-adapter-extension-point.md). An admitted external host +picks up the same capability-driven treatment described here, but it is not one +of the three built-ins this reference compares, and it can never claim +primary-host, AQE-provider, or status-line status. + Evidence cutoff: **2026-08-04**. The comparison was checked against agentic-kit `4.0.0-alpha.36`, Ruflo `3.34.0`, agentic-qe `3.13.x`, RuvNet Brain `4.0.7`, Claude Code `2.1.222`, Codex CLI `0.146.0`, and OpenCode `1.18.x`. Host and diff --git a/docs/ddd/component-directory.md b/docs/ddd/component-directory.md index d571ba7..200f565 100644 --- a/docs/ddd/component-directory.md +++ b/docs/ddd/component-directory.md @@ -108,7 +108,7 @@ lintable where mechanical): ### Iconography Official marks are used only where the dashboard already ships them as official — the three -host SVGs Observability renders on session rows — and are reused byte-identically so a +built-in host SVGs Observability renders on session rows — and are reused byte-identically so a component looks the same everywhere. Every other component gets a **monogram tile**: its initial(s) on a rounded tile in its category hue. A monogram is an honest "no official mark" statement, not a stand-in logo; if an upstream later publishes a usable mark, swapping it in is diff --git a/docs/ddd/ubiquitous-language.md b/docs/ddd/ubiquitous-language.md index 57004d2..89be81a 100644 --- a/docs/ddd/ubiquitous-language.md +++ b/docs/ddd/ubiquitous-language.md @@ -140,8 +140,9 @@ runtime state is a chip word, never a prose word. See ## Usage rules -- Say **host** when referring to Claude Code, Codex, OpenCode, session drivers, leadership, or - activity routing. +- Say **host** for any session driver or activity-routing target the registry recognizes — + Claude Code, Codex, and OpenCode are the built-in examples, not the exhaustive list — and for + leadership. - Say **inference provider** when referring to Anthropic, OpenAI, OpenRouter, Ollama, billing, provider credentials, or inference endpoints. - Qualify **projection** as configuration projection or read-model projection when ambiguity is diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 65cdead..5e48455 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -19,6 +19,7 @@ import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; import { lifecycleAdapterFor } from '../../lib/adapters/lifecycle-registry.mjs'; +import { hostTierLabel, hostAsymmetryNote } from '../../lib/hosts.mjs'; import { routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, } from '../../lib/adapters/index.mjs'; @@ -195,12 +196,13 @@ async function status({ flags, cwd }) { : dflt ? 'enabled (default — ruflo default-on, no env written)' : d.wired ? 'enabled, wired' : 'enabled, not wired → ak sync'; - const tier = h.id === 'opencode' ? dim(' · routing host (ak run; never primary/AQE)') - : dim(' · routing host'); + const tier = dim(` · ${hostTierLabel(h.id)}`); // auth/billing axis — subscription ($0) vs metered key, per host. const auth = d.present ? hostAuthState(h.id, { present: true }) : null; const authStr = auth ? dim(` ${auth.mode}/${auth.billing === 'subscription' ? '$0' : auth.billing}`) : ''; console.log(` ${h.id.padEnd(9)} ${(d.version ? `v${d.version}` : '—').padEnd(12)} ${state}${authStr}${tier}`); + const note = hostAsymmetryNote(h.id); + if (note) console.log(` ${dim(note)}`); } // agentic-qe LLM provider (AQE_LLM_PROVIDER) + fallback chain diff --git a/src/lib/hosts.mjs b/src/lib/hosts.mjs index 197b217..ee86490 100644 --- a/src/lib/hosts.mjs +++ b/src/lib/hosts.mjs @@ -23,7 +23,7 @@ // ~/.codex/auth.json (key overrides login). claude auth on macOS lives in the // Keychain (no readable file); ANTHROPIC_API_KEY, when used, is not a simple // override of a subscription login, so we label it conservatively. -import { HOST_REGISTRY } from './adapters/index.mjs'; +import { HOST_REGISTRY, effectiveHostRegistry } from './adapters/index.mjs'; /** Per-host adapter descriptors. Logical names (`guidanceFile`, `loginFile` * segments) are resolved to real paths by callers so this stays pure. */ @@ -69,3 +69,75 @@ export function drivingHost(env = process.env, cfg = null) { if (primary && primaryCapable) return /** @type {'claude'|'codex'} */ (primary); return 'claude'; } + +/** + * Human phrase for a host's tier, derived purely from its capabilities — never + * from `host.id` (the anti-pattern this replaces: x/host.mjs's status() used + * to special-case `h.id === 'opencode'` for its tier text; D-2/F-25/F-26). + * Accepts a host id (resolved against `registry`, default + * effectiveHostRegistry() so an admitted external host resolves too) or a raw + * host-entry object directly (for a synthetic host not registered anywhere). + * TRUE by construction for any capability combination validateHostAdapter + * accepts, including a future built-in or admitted external adapter — the + * label follows the flags, not a name. + * + * @param {string|object} hostIdOrEntry + * @param {{ registry?: ReadonlyArray, builtins?: ReadonlyArray }} [opts] + * @returns {string} '' when the host isn't found or can't drive a session. + */ +export function hostTierLabel(hostIdOrEntry, { registry = effectiveHostRegistry(), builtins = HOST_REGISTRY } = {}) { + const host = typeof hostIdOrEntry === 'string' + ? registry.find((entry) => entry.id === hostIdOrEntry) + : hostIdOrEntry; + if (!host?.capabilities?.canDriveSession) return ''; + const { canBePrimary, canRouteActivities } = host.capabilities; + + if (canBePrimary) return 'drives sessions · can lead'; + if (!canRouteActivities) return 'drives sessions'; + + const isBuiltin = builtins.some((entry) => entry.id === host.id); + const base = isBuiltin ? 'routing only · supervised' : 'routing only · external adapter'; + return host.legacy?.aqeProvider ? base : `${base} · not AQE`; +} + +/** + * A one-line, capability/trust-derived note about a host's asymmetric + * behavior versus the other managed hosts — cross-host MCP delegation (F-25) + * and the ruflo backend env flag / permission consent boundary (F-26) — + * assembled only from facts that are true FOR THIS HOST's own registry + * entry, never from an id check. '' when nothing asymmetric applies. + * + * @param {string|object} hostIdOrEntry + * @param {{ registry?: ReadonlyArray }} [opts] + * @returns {string} + */ +export function hostAsymmetryNote(hostIdOrEntry, { registry = effectiveHostRegistry() } = {}) { + const host = typeof hostIdOrEntry === 'string' + ? registry.find((entry) => entry.id === hostIdOrEntry) + : hostIdOrEntry; + if (!host?.capabilities?.canDriveSession) return ''; + const notes = []; + + // F-25: this host is registered as callable FROM another host via MCP — a + // real bridge capability, read off the trust manifest rather than an id + // check ('expose