From 8f63d0da1ca46e0f3961abed1a1b9feeccb64dec Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 16:34:07 -0700 Subject: [PATCH 1/2] feat: host tiers and asymmetries render from capabilities, not host names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (D-2, F-25, F-26): hostTierLabel() derives a plain phrase from a host's capabilities — 'drives sessions · can lead' for a primary host, 'routing only · supervised · not AQE' for a supervised one, 'routing only · external adapter' for an admitted external — replacing x/host.mjs's h.id === 'opencode' tier ternary. hostAsymmetryNote() states the cross-host MCP delegation bridge (F-25) and the permission consent boundary / absent backend env flag (F-26) from each host's own trust manifest and capability set. Both are true by construction for any host, built-in or admitted; no new tier jargon. --- src/commands/x/host.mjs | 6 ++- src/lib/hosts.mjs | 74 ++++++++++++++++++++++++++- tests/kit/host-cli-migration.test.mjs | 18 +++++++ tests/kit/hosts.test.mjs | 67 +++++++++++++++++++++++- 4 files changed, 161 insertions(+), 4 deletions(-) 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