What four source-grounded research sweeps β ruflo's plugin history, the three
+hosts' extension models, Hermes at HEAD, and agentic-qe's parity constraints β teach us about
+building agentic-kit's host-adapter extension point without sacrificing quality, symmetry, or
+maintainability. Ends in one finalized mechanism recommendation and the decisions still open.
+
Prepared 2026-08-15 Β· companion to the Host & Provider Consistency master
+document Β· every claim below carries a citation from a live sweep of upstream source
+
+
+
+
1 Β· The manifesto correction
+
"Everything is a plugin" is not ruflo's manifesto. The phrase is the title of
+a blog post for a different rUv product β cognitum-open-design v0.8.0
+(apps/landing-page/β¦/open-design-0-8-0-everything-is-a-plugin.md).
+Training data conflates the two because both are rUv projects with plugin systems; treat any
+unsourced claim of a ruflo "everything is a plugin" manifesto as false.
+
Ruflo's actual founding document is
+v3/implementation/adrs/ADR-004-PLUGIN-ARCHITECTURE.md (Implemented,
+2026-01): "We will adopt a microkernel architecture with plugins for optional features."
+Core stays small (agent lifecycle, task execution, memory, coordination, MCP server); everything
+else is an optional plugin. Notably, one of its own success metrics was still unchecked at ship
+time: [ ] Community plugin contributed. That gap β a designed-for ecosystem that
+did not materialize on the designed timeline β frames every lesson below.
+
+
2 Β· Evidence base
+
+
Sweep
Grounded against
Headline
+
ruflo
ruvnet/ruflo@45e65b5 (HEAD 3.38.11; local CLI 3.38.8)
+
Two plugin layers: the Claude Code plugin catalog (38 plugins, real and disciplined) and
+ the internal IPlugin runtime framework (well-designed; trust layer honestly
+ self-documented as demo/stub).
+
hosts
Claude Code docs (2026-08-14) Β· Codex rust-v0.147.0 Β· OpenCode v1.18.18
+
Manifest-first everywhere mature; Codex's plugin system has shipped and converged
+ with Claude Code's hook taxonomy; nobody sandboxes or signs anything; every lifecycle has
+ real atomicity bugs.
+
hermes
NousResearch/hermes-agent@cb47f59 (245 commits past v0.20.1)
+
Every load-bearing adapter fact holds unchanged. New: a native ACP server (third driving
+ surface), a --tui statusline, three-tier hooks with consent allowlists. PyPI is
+ stale; the npm bridge now ships three bins.
+
agentic-qe
agentic-qe 3.13.10 (local = latest published)
+
Provider set closed at every layer; its real plugin system reaches QE domains only; the
+ vendorOf mirror is in sync; quality-gate anchors are a content-authoring task,
+ not a flag.
+
+
+
3 Β· Five load-bearing lessons
+
+
3.1 Manifests ship; trust layers stall
+
Across every system surveyed, the parts that work are declarative data: Claude Code's
+plugin.json, Codex's semver-required plugin.json, aqe's
+QEPluginManifest with its fail-closed install gate, ruflo's flat
+marketplace.json. The parts that stalled are the code-trust layers: ruflo's typed
+PluginPermissions shipped as types with zero runtime enforcement
+(verified: no "permission"/"capability"/"sandbox" occurrence in either shipped registry file);
+its Ed25519-signed IPFS marketplace fell back to a hardcoded demo list every time by its own
+ADR's admission; no host sandboxes extension code; no host signs anything.
+Design consequence: make the adapter a data artifact validated by the same
+validators built-ins use, and never rely on honor-system runtime capability checks.
+
+
3.2 The designβship gap is the #1 failure mode β counter it with graded ADRs
+
Ruflo's best practice is the antidote to its own failure: ADR-015-v2 carries a dated
+self-graded table (Working / Demo / TBD per feature). The reliable predictor of "is
+this mechanism real" across the whole sweep was whether the ADR carried that table with test
+counts. Ruflo also produced two near-identical, never-reconciled security ADRs from separate
+agent runs (ADR-145 / ADR-337) β a governance failure to design against.
+Design consequence: agentic-kit's extension ADRs ship with a graded table and
+are not marked Accepted until the table is backed by dated evidence; each published contract
+gets its own ADR pinned to an ak version, with a CI check that fails when the pin goes stale
+(ruflo's per-plugin contract ADRs drifted ~32 minor versions unnoticed).
+
+
3.3 Fail-closed admission is the differentiator; per-adapter isolation is table stakes
+
Hosts degrade by default β and their open bugs show even that contract breaking (a removed
+Codex plugin's Stop hook keeps firing until restart
+openai/codex#38339; an OpenCode plugin segfault left installs
+permanently unusable anomalyco/opencode#26890; a Claude Code
+marketplace update reports success while doing nothing
+anthropics/claude-code#46594). Ruflo's admission is warn-only even
+for unsigned plugins. Nobody in this space is fail-closed at the door β agentic-kit already is
+(ADR-0023), and should stay so: an invalid adapter is refused by name, never warned past.
+Equally: one bad adapter must never brick ak β which is precisely the F-01 import-time-throw
+blocker, now with ecosystem-wide evidence behind the fix.
+
+
3.4 Hash-pinned consent is the strongest achievable integrity primitive
+
Codex hash-pins each trusted hook and invalidates trust automatically on any edit β
+the strongest integrity mechanism found anywhere, and cheap. Hermes independently converged on
+per-(event, command) consent allowlists for its shell hooks. Signing exists nowhere.
+Design consequence: record a content hash of the adapter manifest (and each
+declared hook command) at trust time; a changed manifest re-prompts. This composes with ak's
+existing trust-manifest machinery and with aqe's contentHash-frozen anchor
+precedent. Do not require signatures the ecosystem cannot produce.
+
+
3.5 Conformance means black-box subprocess tests against the installed layout
+
Ruflo's hook regression (flags that didn't exist; --success true silently
+recording the string "true" as a path β ADR-102) was
+caught only by a harness that drives the real CLI binary with synthetic stdin and
+asserts negative cases. Its counter-example: a plugin smoke test hardcoding a
+monorepo-sibling path fails for every real installed user
+(ruvnet/ruflo#2912).
+Design consequence: the conformance kit spawns real processes, asserts negative
+cases, runs against an installed topology (not the repo checkout), and returns a numeric
+pass/fail β the contract's graduation evidence.
+
+
4 Β· Hard constraints the design inherits
+
+
AQE's provider set is closed at every layer β 11 declared types, 10
+ constructible (onnx is declared-but-unconstructible); its real plugin system
+ (aqe plugin install) reaches QE domains only. No adapter may ever project into
+ any AQE surface. Phase 1's asymmetry decision already encodes this.
+
Two AQE "host" surfaces exist and must not be conflated: LLM-execution
+ binding (spawns claude/codex; closed) vs. platform/MCP installers
+ (8 hardcoded; closed). ak's extension point extends neither β it extends ak's own host axis.
+
Driving surfaces are plural now. Hermes ships a native ACP
+ (Agent Client Protocol) server alongside CLI oneshot β the contract must express how
+ a host is driven (cli-subprocess, ACP, MCP) as declared capability data, not assume
+ subprocess.
+
Detection can trust nothing indirect: Hermes's PyPI is two releases
+ stale while the unofficial npm bridge tracks releases within an hour and now ships three bins;
+ Claude Code plugin identity falls back through versionβSHAβdigestβunknown. Detect binaries and
+ verify independently; never infer identity or currency from a package registry.
+
Statusline claims must be scoped: Hermes's statusline exists only in its
+ new --tui mode; Codex has no tui.status_line free-text key at all.
+ The capability vocabulary already handles this (commandStatusline) β keep claims
+ mode-scoped and truthful.
+
The vendorOf mirror tracks upstream referee.ts β in sync at
+ aqe 3.13.10 with one documented fail-closed divergence; any upstream prefix-table change needs
+ a mirror update (no version gate exists beyond presence).
+
Ruflo cannot drive Hermes. Its repo description names "Hermes" as
+ integrated, but source shows only technique adoptions from hermes-agent
+ (prompt-caching pattern, reasoning-tag scrubbing, tool-loop breaker β
+ v3/@claude-flow/cli/__tests__/hermes-tier1.test.ts); no Hermes
+ execution backend exists. Hermes reaches ak only through ak's own adapter seam.
+
+
+
5 Β· Interfaces & anatomy β as-is and to-be
+
+
5.1 The interfaces ak actually has
+
Signatures below are read from source on this branch, not recalled. Everything in this table
+is host-neutral, validated, and test-enforced β this is the spine the extension point builds on.
+
+
Axis
Interface
Where
+
Registries
+
HOST_REGISTRY Β· PROVIDER_REGISTRY Β· PROJECTION_REGISTRY Β·
+ OBSERVABILITY_REGISTRY β immutable, cross-axis-validated at construction
+ (validateRegistries throws at load); capability queries
+ hostIds(pred), primaryHostIds(), routableHostIds(),
+ managedHostIds(), hostsWithCapability(), defaultHostMap()
+
adapters/registries.mjs
+
Bindings
+
assertValidBinding (throwing) Β· validateBinding (error list β
+ wired to ak host status warnings) Β· resolveBinding Β·
+ validateEndpoint (loopback-http / remote-https / secret-param rules)
Introspection says the codebase is not a hack job; it is a disciplined core wearing
+an accreted shell. Every contract above was built host-neutral and is enforced by
+validators and tests β several at module construction, which is stricter than most of the
+ecosystem surveyed. The debt is concentrated and countable: roughly ten dispatch sites where a
+command reaches a host by name instead of by lookup β the five
+OPENCODE_LIFECYCLE_ADAPTER named-import call sites (F-02), the frozen three-entry
+execution map whose invariant throws at import (F-01), status's 88-line opencode block (F-05),
+routing's {claude, codex} literals and binary escalation swap (F-23), guidance's
+closed four-target list (F-17), setup's claude-only permission pass (F-04), uninstall bypassing
+runLifecycle (F-03), the fixed quota record (F-10), the two statusline patchers,
+and the hand-mirrored npm package list (F-18). Phases 0β1 already moved attribution, host
+defaults, the binding migrator, and provider records from the shell to the spine. Phase 2's
+waves 1β2 are precisely the remaining shell-to-spine moves β deletion of hand-wiring, not new
+machinery.
+
+
+
+Fig 1 β as-is, conceptual. Two paths run between commands and the
+same contract core. The spine resolves hosts by registry lookup; the shell reaches specific
+hosts by name β each shell item is a finding, and each is a deletion target, not a rewrite.
+
+
+
+
+Fig 2 β as-is, sequence (ak sync wiring pass). The
+config half already runs on the spine. The lifecycle half runs through the full five-verb
+contract β but the adapter is a named import; the same is true at setup, host pick, and host
+off, while uninstall skips the contract entirely.
+
+
+
5.3 To-be: one path, one door
+
+
+
+Fig 3 β to-be, conceptual. The shell is gone: every command
+resolves hosts through one registry lookup. External adapters are data admitted through one
+fail-closed door; a refusal names its reason and cannot disturb built-ins. The contract core
+does not change β it was already right.
+
+
+
+
+Fig 4 β to-be, sequence. Admission happens once, up front, fail
+closed. After that an external host is indistinguishable from a built-in at every call site:
+same five verbs, same supervision, same independent verification, same undo path.
+
+
+
6 Β· The recommended mechanism β Q2, finalized
+
+
An adapter is data plus consented subprocess hooks. No third-party code runs inside ak.
+
Every line of evidence points the same way: the manifest-first layers succeed everywhere;
+the in-process-code trust layers failed at ruflo (types without enforcement), don't exist at
+any host (no sandboxing, no signing), and would make ak the only tool in this ecosystem running
+foreign code in-process on an honor-system cap. Hermes's own extension philosophy β "any CLI
+you already have becomes a plugin without writing code" β plus its subprocess shell hooks with
+consent allowlists, is the shape a Hermes-class adapter naturally wants.
+
+
+
Declarative core: an adapter package is a versioned manifest β the host
+ registry entry (validated by the same validateHostAdapter built-ins pass), plus
+ declarative detection (bin names, version argv + regex), lifecycle described as data where
+ possible, and declared trust changes.
+
Subprocess hooks for the remainder: lifecycle verbs that genuinely need
+ logic run as short-lived subprocess commands declared in the manifest β each hash-pinned at
+ consent time, re-consented on change, executed with ak supervising (timeout, captured output,
+ independent verify after).
+
Capability caps are structural, not runtime: the adapter manifest
+ schema cannot express canBePrimary, aqeProvider, or
+ managed-statusline claims β a cap enforced by shape is stronger than any runtime check, and
+ is precisely where ruflo's honor-system caps failed.
+
Fail-closed admission, isolated failure: a manifest that fails
+ validation, hash check, or contract version is refused with a message naming adapter and
+ reason; a refused or broken adapter never affects built-ins or other adapters (the F-01 merge
+ seam is the enabling refactor).
+
Independent verification: ak never trusts an adapter's (or host's)
+ reported success β post-apply verify is part of the lifecycle contract, per the
+ lifecycle-atomicity bugs found in every host.
+
+
+
7 Β· Contract sketch
+
+
Element
Shape
Precedent
+
Registration
kit.json hostAdapters: [{name, source, contract: 1}] β explicit, never scanned
+
ruflo PluginRegistry.register(); Claude Code --plugin-dir
Waves 1β3 unchanged (in-tree generalization, F-05 status hot zone, test
+ derivations) β every mechanism finding lands in wave 4's shape, not the refactors.
+
Wave 4 is now concrete: manifest schema + admission gate + hash-consent
+ + supervised hook runner + fixture adapter + conformance harness. No module loader for foreign
+ code β which shrinks the security surface the loader review must defend.
+
New wave-4 item: driving-surface vocabulary β capability data expressing
+ cli-subprocess / ACP / MCP driving, so the contract doesn't fossilize the subprocess
+ assumption (Hermes ACP evidence).
+
Phase 3 note: a truthful Hermes adapter today could declare
+ session-driving (oneshot or ACP), transcripts, scoped usage (--usage-file),
+ bidirectional MCP config, and tui-scoped statusline β with nativeGuidance
+ pending a definition check against Hermes vocabulary (AGENTS.md/SOUL.md injection exists;
+ a literal guidance artifact does not).
+
+
+
9 Β· Decision board
+
+
Settled
+
Ownership. agentic-kit authors and owns the design, ADRs, tests, and
+ implementation. PR #131 is credited as the originating proposal; the maintainer comments on
+ it once the Phase 2/3 approach is settled.
Q1 Β· Posture. Staged: in-tree generalization at GA; loader + contract
+ behind an experimental flag with written graduation criteria (recommended) β vs. fully
+ published day one, vs. in-tree only.
+
Open β recommendation stands
+
Q3 Β· Tier vocabulary. Capability-derived phrases in status/about/help
+ (recommended) β vs. tier nouns, vs. role nouns.
+
Open β recommendation stands
+
Q4 Β· Hermes adapter ownership. Fixture-only through Phase 2; revisit an
+ ak-org reference adapter once the contract freezes (recommended) β vs. ak-org adapter now,
+ vs. in-tree host code (rejected).
+
Open β recommendation stands
+
Q5 Β· Docs restructure depth. Targeted Phase 3 sweep of known offenders
+ with appendices, standing current-state-only rule preventing new violations (recommended) β
+ vs. full-corpus audit, vs. separate phase.
+
+
+
10 Β· Sources
+
Each sweep's full citation set lives in its research report; the load-bearing
+references:
agentic-kit Β· how hosts work, and how new ones join
+
Room for more hosts
+
The kit drives work through an AI coding assistant β a host. It ships
+ with three. This effort rebuilt the plumbing so all three are treated the same, and added a
+ door for a fourth, a fifth, a tenth β without rewriting the kit each time.
+
A plain-language explainer for people who use agentic-kit and for people who might
+ write a host adapter for it. Throughout, maintainer means the maintainer of
+ agentic-kit. Companion to ADR-0016 and ADR-0029.
+
+
+
+
+
1 Β· The one-sentence version
+
Adding a new assistant used to mean surgery on a dozen places inside the kit;
+now the three built-in assistants are described by data in one registry, and an outside assistant
+can be described the same way β as a signed-off manifest, never as code that runs inside the kit.
+
+
+
Is this what lets us use Gemini CLI, Hermes, and others?
+
Yes β this is the foundation that makes those possible.
+ It is not yet a finished on-ramp an external assistant can be driven across today. What exists now:
+ the kit can recognize and register an external host from a manifest, safely and
+ reversibly, behind an experimental switch. What comes next is turning that recognition into
+ running work through it. The staging is spelled out in
+ section 5 β honestly, so nobody expects more than is there.
+
+
+
2 Β· Two kinds of host
+
Everything the kit can drive falls into two buckets. The difference isn't the assistant β it's
+how the kit came to know about it.
+
+
+
+Two doors, one hallway. Built-in hosts are compiled into the kit and
+trusted by definition. External adapters arrive as data and must pass a fail-closed admission gate.
+Once inside, both are just entries in one registry β every command treats them the same, which is
+the whole point.
+
+
+
+
+
Built-in hosts here now
+
Claude Code Β· Codex Β· OpenCode
+
+
Ship inside the kit; nothing to install or trust separately.
Claude and Codex can lead; OpenCode is a supervised routing host.
+
This effort made all three consistent β same commands, same truthful reporting.
+
+
+
+
External adapters the foundation
+
Hermes Β· Gemini CLI Β· anything CLI-shaped
+
+
Described by a manifest the adapter author publishes β not bundled in the kit.
+
Enter only through the admission gate, only behind an experimental switch.
+
Cannot self-declare that they lead, bill, or own a status line.
+
Today: can be recognized and registered. Running work through them is the next step.
+
+
+
+
+
3 Β· Why this was worth doing
+
Before this effort, the three assistants were described in a registry but acted on
+by name, scattered across the codebase. Adding a fourth meant finding and editing roughly ten
+places that each hard-coded "claude / codex / opencode" β and missing one meant a new host that
+looked installed but silently misbehaved. That's the "open-heart surgery" problem.
+
+
+
+The value, in one picture. The commands stopped hard-coding host names
+and started asking the registry "who can do this?" That refactor is done for the three built-ins β
+and it's the same machinery an external adapter plugs into, which is why a fourth host no longer
+means touching ten files.
+
+
+
Along the way the same effort fixed a batch of honesty bugs that were quietly mis-labelling work β
+usage records filed under the wrong assistant, live sessions renamed to an internal placeholder,
+vendor-diversity checks that could be fooled. Truthful reporting is part of the value: a kit that
+supports more hosts has to account for them correctly, not just run them.
+
+
4 Β· What an external adapter actually is
+
for implementers The core safety idea: an adapter is
+description plus a few small scripts the kit runs at arm's length β never a plug-in
+loaded into the kit's own process. If a plug-in can crash or take over its host, this is deliberately
+not that.
+
+
+
+Data first, code at a distance. The manifest is validated by the same
+checks the built-in hosts pass, plus caps that make forbidden claims impossible to even write down.
+The only code that ever runs is a hook β a separate, supervised, shell-free subprocess with a
+timeout and a minimal environment. Nothing from an adapter runs inside the kit.
+
+
+
What the kit guarantees about an external host
+
+
Question
Built-in host
External adapter
+
Can it lead a session (be primary)?
yes*
not by self-declaring β earned only
+
Can it claim a billing / quota provider?
yes*
not by self-declaring
+
Can it claim a custom status line?
yes*
not by self-declaring
+
Does any of its code run inside the kit?
it is the kit
no β subprocess only
+
Can it install software silently?
only disclosed steps
no β may not even name a package
+
What if its manifest is edited after it's trusted?
n/a
trust is void until re-approved
+
What happens if it's broken or malicious?
n/a
refused by name; other hosts unaffected
+
+
* "yes" here means a built-in can hold these β which ones actually do varies
+(Claude: all three; Codex: leads and is an AQE provider, but uses a native, not command-backed,
+status line; OpenCode: none). "Primary" means leading the session β a separate axis from
+taking part in ak run, which OpenCode does as a supervised worker (unpacked in
+"Leading vs. being supervised" just below). The safety point stands:
+an external adapter cannot self-declare any of these β the schema has no field to say so β
+but a capability can be earned via conformance
+(section 7).
+
+
Leading vs. being supervised
+
Two capabilities sound similar but are different axes, and the difference is the crux of why
+OpenCode is a full member in some ways and not others. Leading
+(canBePrimary) is a host's authority to anchor a run; being
+supervised (canRouteActivities) is a host's ability to execute an assigned
+step under the runner's control. A host can have one, both, or β for an external adapter that
+hasn't earned leading β just the second.
+
+
+
+One run, two roles. The supervisor turns "add feature" into a
+role-to-host plan and spawns each step as a bounded, supervised worker. The primary host
+(Claude by default; ak host pick --primary-host codex flips the lead) anchors the run β
+the reasoning roles route to it and a failed worker escalates toward it. Claude and Codex each appear
+as both a lead and a worker; OpenCode can only ever be a worker.
+
+
+
+
Dimension
Leading (primary)
Being supervised (routed worker)
+
Capability flag
canBePrimary
canRouteActivities
+
Which built-ins have it
Claude, Codex
Claude, Codex, OpenCode
+
What it means
Anchors the run: the default host, the reasoning roles route to it, and failed workers escalate toward it; it leads dual-host mode.
Executes one assigned activity (architecture, coding, testing, reviewβ¦) that the runner spawned for it.
+
Chosen by
ak host pick --primary-host
the routing policy, per activity (or a run-local --route)
+
Degree of control
leads β decides the shape, anchors escalation
given a task, host, model, and deadline; runs; reports back
+
Bounded?
it is the session lead
yes β timeout, a bounded escalation ladder, a structured result
+
OpenCode
never (canBePrimary: false)
yes β a supervised worker
+
+
So "supervised, never primary" isn't a demotion from ak run β it's the opposite:
+OpenCode does run work in a pipeline, as a supervised worker like any other. What it can't
+do is lead one β be the default, own the reasoning roles, or be the host escalation resolves
+toward. That authority stays with a host that can be primary.
+
+
5 Β· Where we are β and what's honestly next
+
The door is built and its safety is proven. But a door a host can be registered through is
+not yet a door work can be run through. Here is the truthful staging.
+
+
+
+Foundation laid, on-ramp staged. Only the first box is done. Each later
+box is a small, self-contained increment behind the same experimental switch β so nobody is exposed
+to a half-built external host by accident.
+
+
+
What can be expected today
+
+
The three built-in hosts work fully and consistently β that's the immediate,
+ shipped payoff, and it needs no flag.
+
The internals are ready for more β a new host is a registry entry or a
+ manifest, not a rewrite.
+
An external host can be recognized and registered from a manifest, safely and
+ reversibly, behind AK_EXPERIMENTAL_HOST_ADAPTERS=1.
+
+
+
+
Limitations β read before expecting an external host to "just work"
+
+
Work can't be run through an external host yet. The kit can admit one into
+ its registry, but driving a task through it (the ak run path) is not built β an
+ admitted external host currently reports "no execution available" rather than running.
+
There's no command to approve one yet. Approval is checked at admission, but
+ the ak host adapters trust command that would record it isn't built β so in practice
+ admission refuses until that lands. This is the intended next step.
+
Manifests are local files only. Fetching an adapter from npm or a URL isn't
+ built; a manifest is a file path today.
+
An external host can't self-declare that it's primary, an AQE provider, or a
+ status-line owner. That block on self-declaration is permanent β it's the safety
+ invariant. But the capability itself is earnable: pass the matching conformance tier and
+ get a maintainer's grant, and an adapter reaches full parity (the graduation ladder in
+ section 7). One honest exception β being an AQE provider
+ type isn't the kit's to grant; agentic-qe's provider list is defined upstream
+ (section 8).
+
It's experimental. The contract version can still change; nothing about the
+ external-adapter surface is promised stable until the final "Open" stage above.
+
+
+
+
6 Β· For implementers: the shape of an adapter
+
When the on-ramp is complete, authoring a host adapter will mean shipping one manifest and a few
+small hook scripts. The manifest looks like this (illustrative):
The conformance kit is the contract. A committed test harness admits a real
+ fixture adapter, runs its hooks as real subprocesses, and refuses a corpus of bad manifests with
+ exact reasons β the same bar a real adapter (Hermes first) must clear to graduate.
+
Approval is pinned to the bytes. Consent is a hash of the exact manifest;
+ edit one character and the kit asks again. Content is never approved unseen.
+
Refusals are specific. An unknown field, a forbidden capability, a bad
+ contract version, a name that collides with a built-in β each is refused by name, and one bad
+ adapter never disturbs the others or the built-ins.
+
+
+
7 Β· From a contributor's idea to a built-in host
+
This is the part that ties it together: the exact sequence from "someone wants to add Hermes" to
+"a released version of agentic-kit ships Hermes as a first-class built-in." The governing idea β
+an external adapter is experimental until conformance graduates it. Conformance is
+the gate; the maintainer is the judge; there are two places a host can land.
+
+
+
+The full journey. A contributor can go all the way to step 3 alone β
+publish an adapter, and any user opts in behind the experimental flag. Getting into the kit
+starts at step 4: propose it to the maintainer with conformance evidence. The maintainer reproduces
+that evidence and reads the hook scripts (the only part that executes), then chooses a landing spot β
+a vetted external adapter, or a promotion into the built-in registry that ships in the next release
+with no flag and full parity.
+
+
+
What each actor actually does
+
+
Author the adapter.contributor Write the
+ manifest (host descriptor, detection, driving surfaces, lifecycle hooks, disclosed trust changes)
+ and the small hook scripts that implement each lifecycle verb. This is data and a few subprocess
+ scripts β in the contributor's own repo, their own language.
+
Self-test against the conformance kit.contributor
+ The kit ships a black-box harness: it admits the adapter through the real gate, runs its hooks as
+ real subprocesses, and refuses a corpus of bad manifests with exact reasons. The contributor
+ iterates until it reports a clean pass. Conformance is objective β the same harness the
+ maintainer will run, so there are no surprises at review time.
+
Publish.contributor They ship the manifest
+ from their own repo. At this point any user can already opt in β behind
+ AK_EXPERIMENTAL_HOST_ADAPTERS=1, with hash-pinned consent. This path needs
+ nothing from the maintainer; it's the permissionless escape valve. It stays experimental.
+
Propose it.contributor To get it vetted or
+ into the kit, they open a PR/issue attaching the conformance report β the numeric, reproducible
+ pass. That report is the currency of the conversation.
+
Verify.maintainer The maintainer re-runs
+ the conformance kit against the adapter (black-box, reproducible) and reads the hook scripts
+ β the one part that executes β with the same adversarial review discipline the rest of the kit
+ gets. Conformance evidence + an independent reproduction + a hook read = the basis for trust. The
+ maintainer never had to run the contributor's code inside the kit to evaluate it.
+
Decide the tier.maintainer Based on which
+ conformance tiers passed, the maintainer chooses where it lands:
+
+
Bless it as a curated external adapterstays external
+ β add it to a vetted list with its manifest hash pinned. Users still opt in themselves, but now
+ with the maintainer's vouch behind it. Lightweight; good for niche or long-tail hosts the kit
+ doesn't want to own. Still experimental, still capped by what it earned.
+
Promote it into the treebecomes built-in β
+ adopt its host descriptor as a built-in registry entry. Because of the registry-driven refactor
+ this whole effort delivered, that's a normal, small PR β an entry plus a lifecycle
+ adapter plus an About card, following the established pattern. No open-heart surgery.
+
+
+
Release.maintainer The promotion PR goes
+ through the ordinary release process β CI, review, merge. The next version of agentic-kit ships the
+ host as a first-class built-in: it appears for every user with no flag, no
+ manifest, no consent step, participating exactly like Claude, Codex, and OpenCode. The caps are gone
+ because it's now code the maintainer vouches for β that's what graduation means.
+
+
+
+
So, concretely, "a release with Hermes built in"
+
Hermes graduates: its author gets it passing the conformance kit and
+ proposes it; the maintainer reproduces the pass and reviews its hooks; the maintainer promotes its
+ descriptor into the built-in registry (a small PR, thanks to the groundwork); it rides a normal
+ release. From that version on, Hermes is one of the built-in hosts β indistinguishable from
+ Claude/Codex/OpenCode to a user β and its adapter code either ships as bundled hook scripts or gets
+ reimplemented as in-process glue, the maintainer's call. The experimental external-adapter door was
+ the proving ground; being built-in is the diploma.
+
+
+
8 Β· Some ceilings aren't ours to lift β the upstream path
+
agentic-kit doesn't stand alone. It sits downstream of two other systems: ruflo,
+which orchestrates the agent loop, and agentic-qe, which runs quality. A host's full
+participation touches all three layers β and that means a contributor chasing a conformance tier can
+hit a ceiling that agentic-kit cannot lift, because the missing capability lives upstream.
+When that happens, the answer isn't "blocked forever" β it's a deliberate request, carried upstream,
+that lights up in the kit once it ships.
+
+
+
+Downstream, but not powerless. When a conformance ceiling is really an
+upstream one, the kit doesn't fake it or bury it β it files a specific capability request against
+ruflo or agentic-qe, tracks the tier as "gated on upstream," and exposes the capability the moment
+the upstream release lands. The dashed gold arrows are the ask; the green arrows are the payoff.
+
+
+
Which layer owns the blocker?
+
The first diagnostic when a conformance tier can't be met: whose capability is missing?
+
+
The blocker
Owner
What happens
+
Can't run work through the host; no trust command; local-file manifests only
+
agentic-kit
+
The kit's to fix β a normal kit PR. This is the on-ramp work, no one to wait on.
+
Host wants to be a recognized AQE provider type
+
agentic-qe
+
Upstream β the provider list is a closed enum. Request a provider-plugin API. Interim: QE works through the model provider underneath the host, so quality isn't blocked, only the host's own AQE identity is.
+
Host wants to be a native ruflo backend (drive the loop, an ENABLE_* target)
+
ruflo
+
Upstream β backends are defined in ruflo. Request a documented backend-registration path. Interim: the host runs through the kit's own supervised execution, just not as a ruflo-native backend.
+
Host wants to participate in the QE quality gate
+
agentic-kit
+
Mostly the kit's β adopt an anchor set. Not blocked upstream.
+
+
+
+
The request path, and why it's credible
+
A capability request isn't a wish into the void. The kit's maintainer
+ champions it upstream β the same maintainer already contributes to these projects β with a
+ concrete extension point named, not a vague "please support us." The pending dependency is recorded
+ against the conformance tier (gated: agentic-qe#NNN), so anyone reading an adapter's
+ status can see exactly what it's waiting on and why. When the upstream release ships the capability,
+ the kit exposes it in the adapter contract and the tier flips from gated to earnable. This is what
+ keeps "no limitations after conformance" honest: the limitations that are real get a
+ documented route to disappear, layer by layer.
+
+
+
9 Β· OpenCode in practice β a worked example
+
All of this becomes concrete with a host that's already here. OpenCode is a built-in, ak
+installs and configures it, and a user can open the OpenCode CLI on a project and get real benefit β
+yet it isn't a full peer on every layer. Here's exactly what it gets, what it doesn't, and β the
+useful part β who owns each gap. It's the whole three-layer story in one host.
+
+
+
+
What OpenCode gets wired by ak
+
On an ak-configured project, opening the OpenCode CLI comes with:
+
+
Ruflo's full MCP server β memory, routing, swarm tools, registered in
+ OpenCode's own config and auto-approved.
+
RuvNet Brain MCP β grounding in rUv source.
+
Ruflo lifecycle hooks β OpenCode's own edit/tool/task events wired to
+ ruflo, so learning fires as work happens.
+
Managed ruflo agents, skills, and AGENTS.md guidance projected in.
+
+
The orchestration + grounding layers: fully first-class.
+
+
+
What it doesn't β and who owns it
+
+
It never leads (never primary) β by design; it's a
+ supervised host.
+
agentic-qe tools/skills aren't projected in (unlike Claude & Codex) β
+ ak's choice; ak could run aqe's OpenCode installer and doesn't yet.
+
It isn't an AQE provider type (analysis can't run on it) β
+ upstream; agentic-qe's provider list is a closed enum.
+
No command status-line footer, no quota surface, no cross-host bridge β
+ by design.
+
+
+
+
+
+
The pattern, in one host
+
OpenCode is a first-class ruflo + brain host, a
+ supervised (never-leading) execution host, and largely outside the
+ agentic-qe layer β and that last gap splits cleanly the way
+ section 8 describes: "no aqe tools projected in" is ak's to
+ close (a normal kit change), while "can't be an AQE provider type" is an upstream request
+ to agentic-qe. That's the entire model β host layer, lead vs. supervised, and the ak-vs-upstream
+ split β visible in a single assistant available today.
+
+
+
+
Built-in host behavior is authoritative in ADR-0016; the external-adapter extension
+point and its safety model are ADR-0029 (which supersedes ADR-0016's original "closed registry"
+stance, narrowly β nothing under a manifest is ever loaded into the kit's process). The graduation
+ladder β experimental external adapter, earned capabilities via conformance tiers, promotion to
+built-in, and the upstream capability-request path to ruflo and agentic-qe β is the proposed next
+decision (a prospective ADR-0031), not yet ratified. Upstream facts (agentic-qe's closed provider
+enum; ruflo's ENABLE_* backend model) are grounded in a source-cited research sweep of
+agentic-qe@3.13.10 and ruvnet/ruflo@45e65b5. Hermes and Gemini CLI are named
+here as illustrative candidates for the external door, not as hosts the kit supports today.
+ Every claim below was verified against source: the ak side by a full surface sweep of the
+ repository, the Hermes side against NousResearch/hermes-agent HEAD (v0.20.0, 2026-08-13).
+ Finding IDs (F-nn) and decision IDs (D-n) are stable and cross-referenced by the roadmap
+ and the PR #131 review response.
+
+
+
+
0 Β· Executive brief β in plain language
+
For every consumer of agentic-kit β no engineering background needed.
+ Everything after this section is the technical evidence behind this summary.
+
+
What agentic-kit is
+
+ agentic-kit ("ak") is a set-up and housekeeping tool for AI coding assistants. Today it
+ manages three of them β Claude Code, Codex, and OpenCode. It installs them, keeps their
+ settings consistent, routes different kinds of work to the assistant best suited for it,
+ and reports on what they did and roughly what it cost.
+
+
+
The problem
+
+ A contributor asked us to support a fourth assistant β Hermes, notable because it drives
+ AI models that run entirely on your own computer instead of in a vendor's cloud. Studying
+ that request exposed something broader: the kit does not treat its three current
+ assistants evenly. Under the hood, roughly half of the kit treats "an assistant" as a
+ generic thing any newcomer could slot into; the other half has the three names written in
+ by hand. In practice, some features quietly apply to only one or two assistants, a
+ newcomer would be invisible to most of the kit β and, worst of all, a few reports file
+ activity from an unrecognized assistant under the wrong name instead of saying
+ "unrecognized."
+
+
+
The challenges
+
+
Adding an assistant today is open-heart surgery. The last time we added one it
+ touched roughly fourteen files and eight test suites β and permanently made the kit's
+ maintainer responsible for software they don't personally use.
+
Assistants differ in ways users must be told about. Example: in unattended
+ mode, Hermes approves every action automatically, by its own design β where OpenCode
+ pauses and asks. The kit has to state differences like that plainly, never average them
+ away.
+
The bookkeeping isn't honest yet. Usage and session reports currently mis-file
+ unknown assistants into an existing bucket rather than labeling them truthfully. That is
+ a correctness problem today, not a someday problem.
+
Changing course must happen on the record. An earlier design decision
+ deliberately said "no third-party plug-ins." Opening a plug-in door means revisiting that
+ decision explicitly, with its trade-offs written down β not quietly working around it.
+
+
+
The planned work, in phases
+
+
Phase 0 β Truthfulness fixes. Stop mis-filing unknown assistants; replace
+ silences with plain labels ("this assistant has no usage report"); remove duplicated and
+ dead configuration. These are owed no matter what else is decided.
+
Phase 1 β Local models, generically. Recognize any locally-run model server
+ (LM Studio, llama.cpp, MLX, vLLM, and whatever comes next) β not just one vendor β so
+ people running models on their own machines are first-class citizens.
+
Phase 2 β The plug-in door. Rework the kit's insides so an assistant is
+ something the kit looks up rather than something written into it. Then,
+ if the decision to open up is confirmed, publish one well-tested connection point so a
+ new assistant can be added by the people who want it.
+
Phase 3 β Hermes, and honest labels everywhere. Hermes arrives as the first
+ plug-in, maintained by its proposer rather than by us. Every status screen learns to say
+ what each assistant can and cannot do β and why.
+
Phase 4 β Parked, on purpose. Ideas that need more real-world evidence first
+ (deeper usage reporting for plug-ins, a stronger isolation model) are listed and
+ deferred, not forgotten.
+
+
+
What you get at the end
+
+ Whichever assistants you use: the same commands work the same way, reports name things
+ truthfully, and capability differences are stated rather than hidden. Adding tomorrow's
+ assistant no longer depends on one maintainer's spare time. And one thing deliberately
+ does not change: the kit never installs a plug-in on its own β you choose one
+ explicitly, and the kit tells you exactly what it is and what it is allowed to do before
+ anything runs.
+
+
+
+
+
+
1 Β· The state model: a bimodal host axis
+
+ The single most important structural fact: everything that validates a host is generic;
+ nearly everything that acts on a host is hardcoded by name.
+
+
+ The registry, capability vocabulary, and all six validators are host-agnostic and already
+ proven against synthetic hosts (capability-derived.test.mjs exercises a
+ future-host; trust-manifest.test.mjs:47-69 injects a
+ grok host end-to-end through setup disclosure). But dispatch, presentation,
+ attribution, guidance, teardown, and version tracking each carry literal
+ claude/codex/opencode branches. A conforming
+ fourth host registered today would validate cleanly, appear in exactly two surfaces
+ (the setup/sync install loop and the trust manifest), and be invisible β or actively
+ breaking β everywhere else.
+
+
+ This is why "inconsistent UX" is the right frame: the inconsistency is not a Hermes
+ problem. Several gaps below already bite OpenCode, the third built-in host.
+ The extensibility question (D-1) only raises the stakes; it does not create the debt.
+
+
+
2 Β· The de-facto host tiers
+
+ Three tiers already exist in behavior but not in vocabulary. The command surface calls all
+ of them "host," and ak host pick means something different at each tier.
+
+
+
Tier
Hosts
Can do
Cannot do
+
+
Session host
+
claude, codex
+
Drive the ruflo loop (ENABLE_*), be primary, statusline, quota channels, usage scorecard, MCP bridge
+
β
+
+
+
Supervised execution host
+
opencode
+
Route ak run activities; permission events intercepted (permission_required)
Route activities via an externally maintained adapter; host-declared usage sidecar
+
Everything tier 2 cannot, plus no interceptable permission event β hermes -z auto-approves by its own headless contract (disclosed, verified)
+
+
+
+ ADR-0020 already mandates tier 2's shape for OpenCode ("explicit, supervised, non-primary,
+ outside AQE provider routing, absent from vendor-diversity facts"). ADR-0029's capability
+ caps reproduce that shape for external hosts β tested policy, not new policy. What is
+ missing is the vocabulary: no surface tells the user which tier a host is in or why
+ a capability is absent (see D-2).
+
+
+
3 Β· Findings inventory
+
+ Severity: blocker crashes or corrupts;
+ major wrong or missing behavior a user will hit;
+ minor debt, dead code, or docs drift;
+ healthy baseline worth protecting.
+ Scope: today affects built-in hosts now;
+ ext manifests only if extensibility (D-1) proceeds.
+
+
+
3.1 Registry & validation β the healthy baseline
+
+
ID
Finding
Severity
Scope
+
F-00
Registry descriptors, closed capability vocabulary (registries.mjs:6-9,154-225), six host-agnostic validators, validateRegistries at construction, pure capability selectors, graceful runner degradation (runner.mjs:246-252), fully generic trust manifest (trust-manifest.mjs) and footprint install loop (footprint/install.mjs:170-178). Protect this.
healthy
today
+
+
+
3.2 Dispatch & lifecycle
+
+
ID
Finding
Severity
Scope
+
F-01
EXECUTION_ADAPTERS is a frozen 3-entry Map with a bidirectional invariant that throws at module import (execution/adapters.mjs:8-12,20-30). Any registered routable host outside the Map bricks ak run for everyone. ADR-0018 prose states the invariant in one direction only; the code enforces both. The single hardest extensibility gate β absent from ADR-0029 Β§8.
blocker
ext
+
F-02
Lifecycle adapter selection is a named import at five call sites (sync.mjs:11,195-197; x/host.mjs:19,326,617-619,642; setup.mjs:15,216). No lifecycle registry, no host.lifecycle field; validateHostAdapter doesn't accept one. A conforming third-party lifecycle has no dispatch path.
major
ext
+
F-03
ak uninstall bypasses runLifecycle entirely β calls retireOpencode(cfg) directly (uninstall.mjs:112-142). Third-party footprint would persist on disk permanently; even for built-ins, teardown has two code paths.
major
today
+
F-04
Setup permission handling authorizes only claude-host rules; removeUndisclosedPermissions (setup.mjs:95,102,120-128) would strip a third-party host's permissions and fail setup.
major
ext
+
+
+
3.3 Presentation
+
+
ID
Finding
Severity
Scope
+
F-05
status.mjs is split-brained: a generic host loop yields install+auth rows (:500-529), then hand-rolled blocks per host β codex MCP/bridge (:352-382), codex plugins (:387-400), an 88-line opencode block importing 8 symbols (:407-495), codex statusline row (:729-746), a claude-only blocks loop (:670). normalizedFacts is collected but consumed for exactly one field (:505-506) β everything else re-probes. Rendering from normalizedFacts is also the direction of the #129/#133/#136 status-vs-sync projection fixes.
major
today
+
F-06
About directory cards are a frozen literal (dashboard/about-directory.mjs:54-110) with a test-enforced registryβdirectory parity invariant (about-directory.test.mjs:118-140,157-161,381-385; ddd/component-directory.md:164-165). Any registered host without an authored card fails CI; card matching also assumes npmPackage is non-null.
major
ext
+
F-07
Statusline is two host-specific patchers (claude helper, codex TOML) with cfg.statusline.codex as a literal config key; consistent with the commandStatusline cap β but statuslineSupported (hosts.mjs:37,53-55) has zero call sites: a dead capability consumer.
minor
today
+
+
+
3.4 Attribution & observability
+
+
ID
Finding
Severity
Scope
+
F-08
Usage scorecard needs seven per-host edits; worse, unknown hosts are misattributed, not omitted: non-codex sessions collapse to claude (usage-index.mjs:1407-1408), parseFile's 2-branch ternary falls through to parseClaude (:831-848), and a fourth root would collide in the single-flight cache (scanKey, :1169). Aggregation itself is generically string-keyed (:1083) β records just never reach it correctly.
major
today
+
F-09
Live sessions rewrite unknown hosts to 'internal' (live/event-schema.mjs:3,107) β namespace collision β and process-sessions.mjs:49-53 filters unknown binaries out entirely. No opencode transcript reader exists.
major
today
+
F-10
Quota is a fixed {claude, codex} record (quota.mjs:30-31,252-257). Consistent with ADR-0010's sanctioned channels β but hosts without a quota channel are silent rather than labeled ("no quota surface for this host").
minor
today
+
F-11
qeCourt.vendorOf is a literal prefix table (qeCourt.mjs:24-32); any unregistered host id maps to 'unknown', so N distinct third-party vendors collapse into one bucket β vendor-diversity findings can be spuriously tripped or spuriously satisfied.
major
ext
+
F-12
host.observability is declared-but-dead: OBSERVABILITY_REGISTRY is exported (registries.mjs:250) and imported nowhere; no dispatcher maps an observability id to a collector. It looks like the telemetry extension point and is validation metadata only.
minor
today
+
F-13
The provider-binding migrator's defaults map is {claude:'anthropic', codex:'openai'} (adapters/config.mjs:64-79); every other host is stamped unknown-via-<host>, provider null, provenance 'unknown' on every load. Already affects opencode.
major
today
+
+
+
3.5 Config & schema
+
+
ID
Finding
Severity
Scope
+
F-14
kit.json accepts unknown top-level keys silently (blind spread, config.mjs:99-124; envelope check only asserts integrations.hosts is an object, :52-82). A future hostAdapters key on an older ak round-trips untouched and does nothing β a silent no-op with no warning story.
major
ext
+
F-15
The DEFAULTS host map {claude:true, codex:false, opencode:false} is triplicated (config.mjs:28, providers.mjs:547, x/host.mjs:334).
minor
today
+
F-16
validateBinding with an unknown-host error code exists (bindings.mjs:72-84) and is called from nowhere in src/. The registry-aware validation the extension point needs is already written and dead.
minor
today
+
+
+
3.6 Guidance, paths, versions
+
+
ID
Finding
Severity
Scope
+
F-17
Guidance targets are a closed list of four literal names (blocks.mjs:280-292); customBlocks rows are generic over target names but retiredForTarget force-strips rows naming a fifth (:315). Meanwhile host.legacy.guidanceFile is declared, read once (hosts.mjs:34), and consumed by nothing. ADR-0030 Β§6 sidesteps this by reusing the agents target β the trap remains for any host whose file differs.
major
today
+
F-18
versions.mjs:70 hand-mirrors the host npm package list "to avoid an import cycle" β but footprint/install.mjs already imports HOST_REGISTRY, so the cycle is one-directional and solvable. A no-npm host is invisible to the drift nudge.
minor
today
+
F-19
lib/paths.mjs is named per-host constants with no hostDir(id) resolver; the registry carries no path metadata at all, so even a fully-looped consumer has nothing to loop over.
minor
ext
+
F-20
npmRoot(host.install.npmPackage) is called unguarded in footprint/install.mjs; the first host without an npm package breaks the census. (ADR-0029 Β§8 names this; trivially fixable independently.)
minor
ext
+
+
+
3.7 Routing & run
+
+
ID
Finding
Severity
Scope
+
F-23
Routing hardcodes HOST_PROVIDER = {claude, codex} (routing.mjs:25), DEFAULT_ROUTES (:203-216), MODEL_CATALOG (:56-72), and a strictly binary escalation swap β host === 'claude' ? 'codex' : 'claude' (:180,193,354). Plan eligibility itself is generic (:584-598). A third routable host is routable in name only: no ladder can name it and no seed includes it.
major
today
+
F-24
--primary-host validates generically against PRIMARY_HOSTS, but help text is literal (x/host.mjs:87-90) and the fallback unshifts 'claude' (:494).
minor
today
+
F-25
The ClaudeβCodex MCP bridge is literal wiring (providers.mjs:622-681) β correct for a codex-specific feature, but there is no per-host "bridge capability" concept, so the asymmetry (codex delegable via MCP, opencode/hermes not) is nowhere stated to the user.
minor
today
+
F-26
Managed env emits only ENABLE_CLAUDE_CODE/ENABLE_CODEX (providers.mjs:686-698); hosts with no ruflo backend flag are simply absent, with no capability-derived explanation.
minor
today
+
+
+
3.8 Provider axis
+
+
ID
Finding
Severity
Scope
+
F-28
providerEntries derives capabilities from identity comparisons inside a .map (modelDiscovery: id === 'ollama'). Does not survive a second local provider. ADR-0028 Β§4's per-entry-data refactor is correct and needed regardless of the rest of PR #131.
major
today
+
F-29
AQE projection asymmetry: ollama is an AQE provider type; local-openai (ADR-0028) is deliberately not projected. Right call β but it must be stated in the ADR and surfaced in status, or it reads as a bug later.
minor
today
+
F-30
Hermes factual corrections required in ADR-0028/0030 (verified against source, see appendix): unofficial npm bridge exists; lmstudio is first-class, no mlx alias; api_mode: openai is invalid and silently dropped; --max-turns exists on chat -q; config dispatcher pointer wrong.
minor
ext
+
+
+
4 Β· Decision areas
+
+
+
D-1 Β· Extensibility posture the one genuinely user-owned call
+
Do we publish host adapters as an extension point (ADR-0029), stay closed, or absorb Hermes in-tree?
+
AAccept ADR-0029, amended. Publish the seam with the honest gate list (F-01β¦F-06, F-11, F-14, F-21), formally superseding ADR-0016's closed-registry clause. Cost: the in-tree refactors plus a loader and fixture adapter; contract consumers constrain refactors (bounded by the alpha-instability statement). Benefit: fourth-host requests answered once; conformance evidence about our own abstractions.
+
BDecline and reaffirm. Keep ADR-0016's closed registry, record the reaffirmation explicitly, and point Hermes at a fork. Cost: the fork drifts, no conformance evidence, the fifth-host request re-litigates this. Benefit: zero contract obligation while alpha churns.
+
CAbsorb Hermes in-tree (ADR-0017 style). ~14 files and ~8 test suites per host, permanent maintainer obligation for a CLI driven by another vendor's daily release cadence (40+ commits/day observed). The contributor themselves argues against this.
+
Recommendation β Option A, sequenced so the in-tree refactors land first and stand on their own (they delete host-specific code and fix bugs that bite OpenCode today). Critically: Phase 0 hygiene below is owed under every option, including B β misattribution and dead seams are not extensibility problems.
+
+
+
+
D-2 Β· Host tier vocabulary & UX
+
Three behavioral tiers exist (Β§2) with no product vocabulary. How do we make capability differences legible?
+
ACapability-derived labels everywhere. Status, about, and help render a tier label and per-capability explanations derived from the registry ("no quota surface for this host", "auto-approving β no permission event to intercept"). No new config; the registry already knows.
+
BDocs-only tiers. Name the tiers in HOST-SUPPORT.md and leave commands as-is. Cheaper, but the inconsistency the user experiences is in the commands, not the docs.
+
CFlatten. Pretend all hosts are equal. Rejected: it would misrepresent the Hermes approval posture, which ADR-0030 rightly insists on disclosing.
+
Recommendation β Option A. Every absence becomes a stated fact instead of silence: this is the single highest-leverage UX consistency move, it consumes the same capability flags the caps enforce, and it revives the dead consumers (F-07, F-12, F-26).
+
+
+
+
D-3 Β· Dispatch generalization depth
+
How far do the in-tree refactors go?
+
AMinimal: registry-driven lifecycle lookup only (F-02). Unblocks a loader but leaves status, uninstall, and guidance split-brained.
+
BFull generalization: lifecycle lookup (F-02) + uninstall through runLifecycle undo (F-03) + status rendered from normalizedFacts (F-05) + guidance targets derived from the registry (F-17) + the adapters.mjs invariant softened to built-ins with a merge seam (F-01) + setup permission authorization keyed by host (F-04).
+
Recommendation β Option B. Each piece deletes host-specific code, and the status refactor continues the #129/#133/#136 single-projection direction β it must preserve the configured-projection + repoRoot scope gate landed there. F-01's softer rule has in-corpus precedent (ADR-0019:75-76: a rung naming a host with no adapter records cli_unavailable and continues).
+
+
+
+
D-4 Β· Attribution policy for non-first-party hosts
+
Usage, live sessions, quota, and vendor facts currently misattribute or erase unknown hosts. What is the policy?
+
AExclude-and-label now. Unknown/uninstrumented hosts get an explicit bucket keyed by host id β never collapsed to claude (F-08), never rewritten to internal (F-09), never silently absent from quota (F-10). qeCourt.vendorOf returns a per-id unregistered:<host> verdict that never counts toward vendor diversity (F-11) β consistent with ADR-0020's "absent unless independently observed."
+
BIngestion contract later. A v2 adapter contract lets a host declare a usage sidecar (Hermes --usage-file is the model: host-declared, ADR-0021 configured-grade, written even on failure). Real value, but it needs the extension point to exist first and a provenance-grading decision.
+
CStatus quo. Rejected: it is not neutral β it actively corrupts the claude bucket and the internal namespace today.
+
Recommendation β A immediately (it is a correctness fix for the scorecard you already hardened in PR #60), B as a v2 contract item once D-1/Phase 2 lands.
+
+
+
+
D-5 Β· Config & schema hygiene
+
kit.json silently swallows the unknown; host defaults live in three places.
+
AWarn-on-unknown top-level keys (F-14) so a hostAdapters block on an older ak is a visible message, not a silent no-op; derive the DEFAULTS host map from the registry once (F-15); wire validateBinding into the load path or delete it (F-16); make the migrator's defaults registry-driven and stop re-stamping unknown-via-<host> on every load (F-13).
+
BStrict-reject unknown keys. Rejected: breaks forward-compatibility between ak versions sharing a kit.json β warn is the right strength.
+
Recommendation β Option A, all four items in one hygiene pass; each is small and none depends on D-1.
+
+
+
+
D-6 Β· Guidance, paths, versions derivation
+
Three registry fields are declared and consumed by nothing; three consumers hand-mirror what the registry knows.
+
AMake the registry the source: guidance targets derived from a real (non-legacy) registry field (F-17); HOST_PKGS derived from install.npmPackage (F-18); add path metadata / a hostDir(id) resolver or explicitly document paths as first-party-only (F-19); either implement observability dispatch or re-document the field as validation metadata (F-12).
+
BDelete the dead fields. Honest, but forfeits the derivation wins and re-opens each on the next host.
+
Recommendation β Option A for guidance/versions (both fix real drift hazards ADR-0017 Β§retrospective already named); for observability and paths, decide per field β a one-line ADR note ("validation metadata only") is an acceptable terminal state if no consumer is planned.
+
+
+
+
D-7 Β· Provider axis
+
ADR-0028 and the provider registry's shape.
+
AAccept ADR-0028 with the api_mode citation fix (F-30) and an explicit note on the AQE projection asymmetry (F-29); land the per-entry capability refactor (F-28) with it.
+
BVendor enumeration instead (mlx, lmstudio, β¦). Rejected in the ADR itself, and our verification strengthens the rejection: MLX has no Hermes alias either β users name their own providers.
+
Recommendation β Option A. This is the easiest accept in PR #131: small, independent, fixes F-28, and useful to the hosts already shipped.
+
+
+
5 Β· ADR amendments register
+
The prior decisions this work touches, and what happens to each. Nothing is amended silently.
+
+
ADR
Clause at stake
Action
Trigger
+
0016
"A closed, validated registry of built-in code, not an arbitrary third-party plugin runtime" (:59-61; non-goal :441-442)
Supersede that clause if D-1 = A; reaffirm explicitly if D-1 = B. Either way, decide on the record β ADR-0029 currently routes around it.
D-1
+
0018
Routabilityβadapter invariant stated one-directionally in prose; enforced bidirectionally at import (F-01)
Amend to the decided rule: bidirectional for built-ins, merge seam for registered adapters.
D-3
+
0019
cli_unavailable graceful degradation (:75-76)
Reaffirm; cite as the degradation norm F-01's softening follows.
D-3
+
0010
Sanctioned quota channels (statusline tee, codex app-server)
Amend: quota channels are a per-host capability; hosts without one are labeled, not silent (F-10).
D-2/D-4
+
0021
Provenance grades for inference evidence
Extend for host-declared usage sidecars (configured grade; Hermes --usage-file is the reference case).
D-4 (B)
+
0020
OpenCode's supervised, non-primary shape
Amend to name the host-tier taxonomy (Β§2) as product vocabulary.
D-2
+
0028
New β local OpenAI-compatible provider
Accept after the api_mode: chat_completions citation fix + F-29 note.
D-7
+
0029
New β extension point
Amend then accept (if D-1 = A): full gate list (F-01β¦F-06, F-11, F-14, F-21), ADR-0016 supersession, older-ak silent no-op mitigation.
D-1
+
0030
New β Hermes reference adapter
Correct then accept, contingent on 0029: F-30's four factual fixes. Its safety posture (YOLO disclosure, reverse-bridge exclusion) is verified and right.
D-1
+
0031 (new)
β
Author: "Host tiers and attribution surfaces" β records D-2 + D-4(A) as one decision, whichever way D-1 goes.
D-2/D-4
+
+
+
6 Β· Test & docs remediation
+
Tests that pin the host set (F-21, F-22)
+
+
Hard pins (7):hosts.test.mjs:10-11 (deepEqual(HOST_IDS, [...])); providers.test.mjs:222-225 (test name says "come only from registry capabilities", body hardcodes the set β fix the body to match the name); about-directory.test.mjs:118-140, 157-161, 381-385 (parity + order); execution-runner.test.mjs:459-469 (pins the import-time invariant); routing-primary.test.mjs:45-48 (stays green under the capability cap).
Fix pattern: derive expected sets from the registry (scoped to built-ins where the assertion is about built-ins), never from a fresh literal.
+
Sentinel hazard: five negative tests use gemini as the "unknown host" β they silently invert if that id ever registers (hosts.test.mjs:40-41,106-108; setup-host-flags.test.mjs:48-53; routing.test.mjs:155-158,163-169,212-217). Replace with an unmistakably synthetic id.
+
+
Docs stating a closed set as normative (F-27)
+
+
docs/HOST-SUPPORT.md β "the three execution hosts" + five fixed-arity matrices; consider generating the matrices from the registry.
+
docs/ddd/ubiquitous-language.md:143 β defines "host" by enumeration.
+
docs/PROVIDERS.md Β§3.5 β routing table prose.
+
docs/ddd/component-directory.md:164-165 β the parity invariant that is also test-enforced (F-06).
+
+
+
7 Β· Sequenced roadmap
+
+
+
Phase 0 β Consistency hygiene owed regardless of D-1
+
No decisions required Β· every item bites built-in hosts today Β· each independently shippable
+
+
F-08: unknown-host usage records β explicit per-id bucket, kill the collapse-to-claude and the scanKey collision.
+
F-09: live-session unknown hosts β explicit bucket, not 'internal'.
+
F-11: vendorOf β registry-driven with unregistered:<id>, never counted toward diversity.
+
F-13: migrator defaults registry-driven; stop restamping on every load.
+
F-15/F-16: single registry-derived DEFAULTS; wire or delete validateBinding.
+
F-07/F-12: remove or revive the dead capability consumers (statuslineSupported, observability field) β one-line ADR note if terminal.
+
F-20 + the plain-text subprocess capture: tiny, independently useful, and they de-risk Phase 3.
+
F-22: replace the gemini sentinels.
+
+
+
+
+
Phase 1 β Provider axis (D-7)
+
Accept ADR-0028 Β· independent of everything else
+
+
F-28: providerEntries β per-entry capability records (matches hostEntries).
+
local-openai row + user-declared bindings; F-29 asymmetry stated in ADR and surfaced in status; F-30 api_mode citation fix.
+
+
+
+
+
Phase 2 β The extensibility decision (D-1, D-3) and in-tree generalization
+
Gate: ADR-0016 supersession + amended ADR-0029 accepted Β· refactors delete host-specific code and stand alone even if the loader never ships
Subprocess/RPC adapter protocol (sandboxable, language-agnostic) β deferred per ADR-0029 Β§3 until a second adapter exists to generalize from.
+
Semver stability promise for the adapter contract β a GA-time decision (ADR-0020 companion).
+
+
+
+
8 Β· Appendix β Hermes verification summary
+
+ Verified against NousResearch/hermes-agent HEAD (v0.20.0, 2026-08-13; the files underlying
+ the claims last changed on or before 2026-08-08, so HEAD matches the ADRs' basis).
+ 5 of 7 claim groups confirmed at source level.
+
+
+
Claim group
Verdict
Notes
+
Python CLI, pip/uv, no npm
partial
Official: correct. But an unofficial npm bridge exists (hermes-agent by wyrtensi, v0.20.0, ships hermes/hermes-agent bins). ADRs must say "no official npm package"; detection must not infer identity/version from npm presence. PyPI carries 0.19.0.
+
YAML config, HERMES_HOME, profiles
partial
All confirmed; config actions are a superset (show|edit|get|set|unset|path|env-path|check|migrate). Pointer wrong: dispatcher is cmd_config in hermes_cli/subcommands/config.py, not config_command.
+
mcp add EOF-default silent no-op
confirmed
All five sub-claims; stronger than claimed β nohermes mcp subcommand except install can signal failure via exit code (returns swallowed at mcp_config.py:1073 β main.py:12872).
+
Oneshot -z behavior
confirmed
YOLO/accept-hooks env, devnull redirect, final-write channel, exit codes 0/1/2 exact, --usage-file on failure too. One correction: --max-turns exists on hermes chat (non-interactive via -q) β just not on -z.
+
Local provider aliases
partial
ollama/vllm/llamacpp β custom: confirmed (in auth.py, not runtime_provider.py). lmstudio is first-class with its own URL normalizer; no mlx alias. And api_mode: openai β as quoted in ADR-0028's reference config β is invalid and silently dropped; the valid value is chat_completions (newer key: transport).
+
mcp serve is a messaging bridge
confirmed
Exactly ten tools (conversations_list, messages_send, permissions_respond, β¦); zero delegation-shaped tools. The reverse-bridge exclusion is the right security call.
+
Docs corroboration
confirmed
Authoritative CLI reference is website/docs/reference/cli-commands.md (not the README). Local-model guides confirm MLX/llama.cpp via custom endpoints.
+
+
+
+ Bottom line. The contributor's evidence is accurate where it counts β every claim about
+ ak's internals was confirmed by the surface sweep, and both Hermes safety findings are real.
+ The gap is scope, not honesty: "a loader and two refactors that delete code" is nearer to
+ three blockers, eight majors, twelve test pins, and a docs corpus. Phase 0 is owed today under
+ every posture; the extensibility decision (D-1) is the only call that is genuinely yours alone.
+
+
+
+ Sources: ak surface sweep (file:line citations verified in-repo) Β· NousResearch/hermes-agent
+ source verification Β· docs/adr corpus Β· PR #131 diff. Companion: the PR #131 review response
+ references F-nn / D-n IDs from this document.
+
+
+
+
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/PROVIDERS.md b/docs/PROVIDERS.md
index b2defa6..3a22058 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -47,6 +47,56 @@ checks every binding declared in `kit.json` and prints a warning naming any entr
unknown host, unknown provider, or unsupported transport β warnings only; nothing is changed
or removed on your behalf.
+## Local OpenAI-compatible servers
+
+Running a local model behind an OpenAI-compatible endpoint β MLX, LM Studio, `llama.cpp`, vLLM β
+rather than Ollama? Declare it as a `local-openai` binding in `kit.json`:
+
+```json
+{
+ "integrations": {
+ "bindings": [
+ {
+ "id": "mlx-via-codex",
+ "host": "codex",
+ "provider": "local-openai",
+ "transport": "openai-compatible",
+ "endpoint": "http://127.0.0.1:8080/v1"
+ }
+ ]
+ }
+}
+```
+
+This gets you a named local inference target with `$0` billing and configured-grade provenance,
+however the endpoint is served. `local-openai` is not an AQE provider type β `ollama` is. Loopback
+`http://` is allowed; a remote endpoint requires `https://`; and the endpoint may never embed
+credentials, fragments, or secret-bearing query parameters. See
+[ADR-0028](adr/0028-local-openai-compatible-providers.md).
+
+## External host adapters (experimental)
+
+Want `ak` to manage a host CLI it doesn't ship in-tree β driving local models through something
+like Hermes, say? Set `AK_EXPERIMENTAL_HOST_ADAPTERS=1` and declare it as **data**, never code:
+
+```json
+{
+ "hostAdapters": [
+ { "name": "hermes", "source": "~/.config/ak/adapters/hermes.json", "contract": 1 }
+ ]
+}
+```
+
+An adapter is a manifest plus a handful of subprocess hooks β nothing an adapter declares ever
+runs inside the `ak` process itself. Registering one asks you to confirm a content hash of the
+manifest; edit the manifest afterward and that consent is invalidated until you confirm again. A
+broken adapter is reported and skipped β it never takes down the hosts that already work.
+
+An external adapter can never claim to be the primary host, an AQE provider, or the status-line
+owner; those stay first-party. Nothing here installs itself: you declare the adapter, you consent
+to it, and teardown remains reversible. See
+[ADR-0029](adr/0029-host-adapter-extension-point.md).
+
---
## Level 0 β do nothing (the point)
@@ -340,5 +390,7 @@ just makes the good default automatic and the customization reversible.
[ADR-0006](adr/0006-primary-host-and-ambidextrous-mirroring.md).
- Capability-driven integration axes, bindings, and provenance:
[ADR-0016](adr/0016-capability-driven-integration-adapters.md).
+- The generic local OpenAI-compatible provider: [ADR-0028](adr/0028-local-openai-compatible-providers.md).
+- External host adapters (experimental): [ADR-0029](adr/0029-host-adapter-extension-point.md).
- Host env flags (`ENABLE_CLAUDE_CODE` / `ENABLE_CODEX`): upstream ruflo
ADR-034, "Optional MCP Backends".
diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md
index 6de247d..5298435 100644
--- a/docs/TRANSCRIPTS.md
+++ b/docs/TRANSCRIPTS.md
@@ -202,8 +202,7 @@ The file is parsed with `withTurns: true` by the provider's parser
(`usage-index.mjs:1518-1545`) with the same fields the Sessions view rows
carry β `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`,
`models`, `tools`, `skill`/`plugin`, worktree β plus a `cost` priced from the
-same per-model usage rows `aggregate()` uses (the header used to render a
-hardcoded `$0.00`; the comment at the site records why).
+same per-model usage rows `aggregate()` uses.
### 4.3 Mask, then truncate β both marked, differently
@@ -363,6 +362,11 @@ was wrong before, for the curious.
- **Session expander fields shipped but unrendered.** The per-session fields
Β§6.1's expander now renders (classification `basis` + confidence, the
token split, flags) once travelled on the wire and rendered nowhere.
+- **Transcript header once showed a hardcoded `$0.00`.** `readSession`'s
+ assembled `meta` left `cost` undefined, and `fmtUsd(undefined)` renders the
+ truthy string `"$0.00"` β a fixed-looking zero on a panel whose whole
+ subject is cost. `meta.cost` is now priced via `sessionCost()` from the
+ same per-model usage rows `aggregate()` uses (`usage-index.mjs:1538-1542`).
- **Aggregate-side incidents** (the v4/v5 cache bumps, the Codex parsing
defects) are recorded in `USAGE-SCORECARD-METRICS.md` Appendix A.
diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md
index 29e0416..630ec6c 100644
--- a/docs/adr/0016-capability-driven-integration-adapters.md
+++ b/docs/adr/0016-capability-driven-integration-adapters.md
@@ -1,9 +1,10 @@
# ADR-0016 β Capability-driven host, provider, binding, projection, and observability adapters
- **Status:** Accepted; compatibility clauses superseded by
- [ADR-0020](0020-ga-stable-surfaces.md)
+ [ADR-0020](0020-ga-stable-surfaces.md); closed-registry clause superseded by
+ [ADR-0029](0029-host-adapter-extension-point.md)
- **Date:** 2026-07-28
-- **Updated:** 2026-08-14
+- **Updated:** 2026-08-15
- **Update note:** Added read-only Codex plugin-hook compatibility facts,
runtime-selected Ruflo project-memory store proofs, and the non-correlatable
OpenRouter account-analytics boundary; removed the pre-GA compatibility command,
@@ -18,6 +19,12 @@
warnings (F-16); and the integrations migrator derives each host's native
default provider from the provider registry's host-login entries instead of a
literal map, inferring no binding at all for hosts without one (F-13).
+ 2026-08-15: [ADR-0029](0029-host-adapter-extension-point.md) supersedes Β§1's
+ closed-registry requirement β the registry admits an explicitly registered,
+ hash-pinned, subprocess-only external host adapter behind an experimental
+ flag; every other property that clause protected (zero-runtime-dependency,
+ offline-first normal operation, no in-process third-party code) remains
+ intact.
- **Deciders:** agentic-kit maintainers
- **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md),
[ADR-0003](0003-auto-seed-dual-host-provenance.md),
diff --git a/docs/adr/0028-local-openai-compatible-providers.md b/docs/adr/0028-local-openai-compatible-providers.md
new file mode 100644
index 0000000..1b6d635
--- /dev/null
+++ b/docs/adr/0028-local-openai-compatible-providers.md
@@ -0,0 +1,178 @@
+# ADR-0028 β One generic local OpenAI-compatible provider, not a vendor enumeration
+
+- **Status:** Accepted
+- **Date:** 2026-08-11
+- **Updated:** 2026-08-14
+- **Update note:** Accepted with corrections after review of PR #131: the quoted Hermes
+ `api_mode: openai` value is annotated as invalid rather than reproduced as valid (F-30), and the
+ AQE-projection asymmetry between `ollama` and `local-openai` is now stated explicitly as
+ intentional (F-29). Implemented with in-tree projections `['ruflo', 'codex', 'opencode']`.
+- **Deciders:** agentic-kit maintainers
+- **Related:** [ADR-0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md),
+ [ADR-0016](0016-capability-driven-integration-adapters.md),
+ [ADR-0021](0021-inference-provider-provenance.md)
+
+Proposed by [@adrianco](https://github.com/adrianco) in
+[PR #131](https://github.com/pacphi/agentic-kit/pull/131); accepted with the corrections recorded
+below.
+
+## Context
+
+[ADR-0016](0016-capability-driven-integration-adapters.md) separates inference **providers** from
+execution **hosts**, and [ADR-0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md)
+governs what a local provider may claim. The provider registry declared exactly **one** local
+provider β `ollama` β and `BUILTIN_BINDINGS` carried exactly two local bindings,
+`ollama-via-claude` and `ollama-via-codex` (`src/lib/adapters/registries.mjs`,
+`src/lib/adapters/bindings.mjs`).
+
+Ollama is not the only way a local model is served, and on real machines it is frequently not the
+one in use. A local inference server is normally reached as an **OpenAI-compatible HTTP endpoint on
+loopback**: MLX/`mlx_lm.server`, LM Studio, `llama.cpp`'s server, and vLLM all present that shape.
+Nothing in ak could name such an endpoint as a provider, so a machine running one had its local
+inference either invisible or misfiled.
+
+This was observed on the proposer's reference machine. `~/.hermes/config.yaml` declared:
+
+```yaml
+provider: mlxlocal
+providers:
+ mlxlocal:
+ api: http://127.0.0.1:8080/v1
+ api_mode: openai
+ default_model: mlx-community--Qwen3-Coder-Next-4bit
+```
+
+`api_mode: openai` is not a valid Hermes value β verified against `NousResearch/hermes-agent`
+v0.20.0's `_parse_api_mode`, which silently **drops** an unrecognized value rather than raising.
+The valid set is `{chat_completions, codex_responses, anthropic_messages, bedrock_converse,
+codex_app_server}` (the newer key spelling is `transport`); `chat_completions` is the correct value
+here, and it is also the default the parser falls back to, which is why the endpoint worked despite
+the invalid setting. The quote above is reproduced verbatim because it is what the proposer's
+machine observed β but `api_mode: openai` is not read as valid Hermes configuration by this ADR.
+
+Two facts survive that correction. First, the endpoint is a plain OpenAI-compatible loopback URL β
+the generic shape, not a vendor-specific protocol. Second, **the provider name is user-chosen**
+(`mlxlocal`). No enumeration of vendor ids can cover that case; a registry that lists `mlx`,
+`lmstudio`, `llamacpp`, and `vllm` still has no row for `mlxlocal`.
+
+The binding machinery already accommodates this. `validateEndpoint` accepts loopback `http://`
+while rejecting remote `http://`, embedded credentials, fragments, and secret-bearing query
+parameters (`src/lib/adapters/config.mjs`). `http://127.0.0.1:8080/v1` was already a legal binding
+endpoint; only the provider row was missing.
+
+## Decision
+
+### 1. Add one generic provider row: `local-openai`
+
+A single provider represents "an OpenAI-compatible model server the user runs locally", regardless
+of which program serves it:
+
+- `billing: 'local'`, `credentials: { kind: 'none' }`, `capabilities.pricing: 'zero'` β required by
+ the registry's own construction invariants for a local provider (`validateRegistries`), and
+ correct: a loopback server bills nothing. A server that wants a placeholder token does not make
+ the credential *required*, so `kind: 'none'` remains accurate.
+- `transports: ['openai-compatible']` β the only transport the row may claim. Anthropic-compatible
+ and native shells stay Ollama's, established separately.
+- `capabilities.modelDiscovery: false`, `runtimeDiscovery: false`, `quota: false`,
+ `cacheAccounting: 'unknown'`. A generic endpoint exposes no catalogue ak may rely on. Claiming
+ `/v1/models` discovery would assert a uniformity across MLX, LM Studio, llama.cpp, and vLLM this
+ ADR has not measured.
+- `observability: []`. Ollama keeps `ollama-catalog` / `ollama-runtime`; the generic row gets
+ neither, because it has no daemon API ak has verified.
+
+`ollama` is unchanged. It keeps its richer transports and its two observability sources precisely
+because those rest on a specific, known daemon.
+
+### 2. The endpoint carries the identity; the provider row does not
+
+Which program serves a `local-openai` binding is recorded as the **binding's** endpoint and model,
+not as provider identity. A user running MLX on `:8080` and LM Studio on `:1234` has two bindings
+against one provider β the same relation ADR-0011 already names for `ollama-via-claude` /
+`ollama-via-codex`, one level more general.
+
+Consistent with [ADR-0021](0021-inference-provider-provenance.md), such a binding establishes
+**configured** provenance and nothing stronger. The endpoint is user-declared, so it may not be
+displayed as observed, and it does not upgrade model, token, cache, or digest claims. The `$0`
+claim is the one exception and is a property of the billing type, not of evidence about the run.
+
+### 3. No built-in bindings for the generic provider
+
+`BUILTIN_BINDINGS` gains nothing here. Ollama's two rows are justified by a fixed, well-known
+default port; a generic local endpoint has no default ak may presume. Bindings are declared by the
+user in `kit.json` and validated by the existing `assertValidBinding` path. "Local" is a billing
+claim (user-run, `$0` β ADR-0011), not a topology constraint: a binding may name a user-run server
+on another machine over `https`, while plain `http` remains loopback-only per `validateEndpoint`.
+
+### 4. Replace the derived capability block with per-entry data
+
+`providerEntries` previously derived capabilities from identity comparisons inside a `.map`
+(`modelDiscovery: id === 'ollama'`, `pricing: id === 'ollama' ? 'zero' : β¦`). That construction does
+not survive a second local provider: `local-openai` needs `pricing: 'zero'` without
+`modelDiscovery`, which the `id === 'ollama'` coupling cannot express. Provider entries become
+explicit records carrying their own capability block, matching how `hostEntries` is already
+written.
+
+### 5. `local-openai` projects to `['ruflo', 'codex', 'opencode']`, and is not an AQE provider type
+
+The in-tree row declares projections `['ruflo', 'codex', 'opencode']`. Two omissions, both
+deliberate:
+
+- **No `'claude'` projection.** The row claims only the OpenAI-compatible transport; Claude's
+ projection expects an anthropic-compatible surface, which is Ollama's arrangement, not this
+ provider's.
+- **No `'aqe'` projection β `local-openai` is not an AQE provider type; `ollama` is.** AQE's
+ provider set is upstream's own enumeration (`ollama`, `onnx` are its local types), not something
+ ak may extend by adding a row to its own registry. Projecting `local-openai` into AQE would
+ fabricate a provider identity AQE has never declared it understands. This is an intentional
+ asymmetry, not a bug: `ollama` gets AQE projection because AQE names it; `local-openai` does not,
+ because AQE does not. `ak status`'s provider surface reflects this distinction; surfacing it
+ clearly is a sibling work package's scope, not this ADR's.
+
+## Consequences
+
+- A machine serving models from MLX, LM Studio, llama.cpp, vLLM, or anything else speaking
+ OpenAI-compatible HTTP on loopback can be described to ak without a new ADR per vendor, and
+ without inventing a provider id the user did not choose.
+- Every host that can be pointed at an OpenAI-compatible base URL gains a nameable local provider.
+- The generic row deliberately supports **less** than `ollama`: no catalogue, no runtime probe, no
+ digest. Surfaces that show local-model detail for Ollama will show less for `local-openai`, and
+ that gap is the honest reading of the evidence, not a defect to paper over.
+- `local-openai` is not an AQE provider type and is not projected as one. AQE's own local routing
+ (`ollama`, `onnx`) is a separate axis, defined upstream, and untouched by this ADR.
+
+## Alternatives considered
+
+- **Named rows per runtime (`mlx`, `lmstudio`, `llamacpp`, `vllm`).** Rejected for this revision on
+ two grounds. It cannot cover a user-named provider such as the observed `mlxlocal`, so the
+ generic row is required regardless and the named rows would be additive decoration. And each row
+ would assert transport and discovery facts for a server this repository has not measured β
+ precisely the derivation-without-measurement that
+ [docs/LOCAL-MODEL-VALIDATION.md](../LOCAL-MODEL-VALIDATION.md) exists to correct. Named rows
+ remain available later, gated on an evidence pass of the same kind, and would then be able to
+ claim real `/v1/models` discovery instead of guessing at it.
+- **Extend `ollama` to mean "any local server".** Rejected: it would make an established provider
+ id lie about which daemon is answering, and `ollama-catalog` / `ollama-runtime` would be attached
+ to endpoints that serve neither.
+- **Infer the runtime by probing the endpoint.** Rejected as a default: ak would be spawning
+ network probes during status collection to manufacture an identity claim that ADR-0021 would then
+ have to grade as inferred anyway. The user naming their own binding is cheaper and more honest.
+
+## References
+
+- `src/lib/adapters/registries.mjs` (`providerEntries`, `validateProviderAdapter`,
+ `validateRegistries` local-billing invariants), `src/lib/adapters/bindings.mjs`
+ (`BUILTIN_BINDINGS`, `assertValidBinding`), `src/lib/adapters/config.mjs` (`validateEndpoint`
+ loopback rule).
+- ADR-0011 (local-model provenance, `$0`, transcript fidelity), ADR-0016 (provider/binding
+ separation), ADR-0021 (provenance is carried, never upgraded).
+- Observed local configuration: `~/.hermes/config.yaml` on the proposer's reference machine
+ (`api: http://127.0.0.1:8080/v1`); the file's `api_mode: openai` is not a valid Hermes value (see
+ Context) and is not cited as correct usage.
+- Hermes source verified for the `api_mode` correction: `NousResearch/hermes-agent` v0.20.0,
+ `_parse_api_mode`.
+- [PR #131](https://github.com/pacphi/agentic-kit/pull/131) β original proposal, including
+ companion proposals for a host-adapter extension point (that PR's ADR-0029) and a Hermes reference
+ adapter (that PR's ADR-0030), neither adopted into this repository by this ADR.
+- Tests: `tests/kit/adapter-registries.test.mjs` (deep-equality pin for the five pre-existing
+ providers, registry invariants for a second local provider, binding validation against a
+ loopback OpenAI-compatible endpoint).
diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md
new file mode 100644
index 0000000..1998718
--- /dev/null
+++ b/docs/adr/0029-host-adapter-extension-point.md
@@ -0,0 +1,373 @@
+# ADR-0029 β External host adapters: declarative manifest, subprocess hooks (experimental contract)
+
+- **Status:** Accepted (experimental contract)
+- **Date:** 2026-08-15
+- **Updated:** 2026-08-16
+- **Update note:** [ADR-0031](0031-capability-graduation-and-upstream-requests.md) amends this ADR's
+ "permanent caps" framing. The block on *self-declaring* `canBePrimary` / `aqeProvider` /
+ `commandStatusline` in the manifest is permanent (the safety invariant here), but the *capability*
+ is earnable through a conformance tier plus a maintainer grant recorded outside the manifest β up
+ to promotion to a first-party built-in. The schema, admission gate, consent model, and hook runner
+ in this ADR are unchanged.
+- **Deciders:** agentic-kit maintainers
+- **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md) (closed-registry clause
+ superseded β see [Supersession](#supersession-of-adr-0016s-closed-registry-clause)),
+ [ADR-0017](0017-opencode-host.md), [ADR-0018](0018-generalized-host-worker-execution.md),
+ [ADR-0019](0019-escalation-in-ak-run.md),
+ [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md),
+ [ADR-0028](0028-local-openai-compatible-providers.md)
+
+Proposed by [@adrianco](https://github.com/adrianco) in
+[PR #131](https://github.com/pacphi/agentic-kit/pull/131), as an in-process ESM module β one
+manifest export, dynamically `import()`-ed from a package path named in `kit.json`, validated by
+the same validators as a built-in host. **Accepted with a different mechanism**, recorded below,
+after maintainer review found that shape's in-tree gate list materially incomplete and its
+underlying safety premise unmet.
+
+## Context
+
+[ADR-0016](0016-capability-driven-integration-adapters.md) deliberately built a **closed** registry:
+"a closed, validated registry of built-in code, not an arbitrary third-party plugin runtime." Every
+host added since β OpenCode in [ADR-0017](0017-opencode-host.md) β paid that cost in full: a
+dedicated owner module, native surfaces reverse-engineered from scratch, and edits across roughly a
+dozen files and eight test suites.
+
+PR #131 named the fourth-host problem this creates: a new host is a **permanent obligation of
+whoever maintains agentic-kit**, including a CLI they may not run, cannot easily verify, and did not
+choose. Its own `docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md` observed, correctly, that ADR-0016's own
+contracts β `validateHostAdapter`, `validateLifecycleAdapter`'s five-verb
+detect/plan/apply/verify/undo cycle, ownership receipts, `validateExecutionAdapter`,
+`normalizedFacts` β are already host-agnostic and already partly proven: `OPENCODE_LIFECYCLE_ADAPTER`
+is driven through the fully generic `runLifecycle`, not opencode-specific dispatch. The proposal's
+central claim was that publishing that seam is a small last mile, not a new subsystem.
+
+The maintainer's review of PR #131 (posted to the PR, quoted throughout this ADR) verified that
+claim on the ak side β "adapter selection is indeed a named import at the five call sites,"
+`status.mjs`'s hand-rolled opencode block and unguarded `npmRoot` call are as described, and the
+validators are genuinely host-agnostic (a synthetic `grok` host is already exercised end-to-end
+through setup disclosure in `trust-manifest.test.mjs:47-69`). It also found the in-tree gate list the
+PR offered materially incomplete, and directed that any accepted design carry the honest full list
+rather than the honest case-against alone. Both findings are the direct inputs to this ADR.
+
+## Why the mechanism changed
+
+PR #131's proposal was an **in-process** extension: a third party's ESM module, loaded by `import()`
+and executed inside the same process as `ak` itself, gated only by explicit registration,
+capability-cap fields in its declared manifest, and disclosure before mutation. Those three
+constraints are real, but they only make untrusted in-process code *visible* β none of them make it
+*safe*, and the evidence gathered in reviewing this proposal argues the gap is not closable by
+adding more constraints of the same kind:
+
+1. **No host CLI ak already integrates or evaluated sandboxes or verifies extension code before
+ running it.** [ADR-0018](0018-generalized-host-worker-execution.md)'s own trust-boundary section
+ already states this of `ak run`'s built-in workers: a hostile repository's `opencode.json` or
+ `.claude/settings.json` pre-approves permissions and "no `permission.updated` event ever fires,
+ so the adapter's abort boundary does not trip by design β the abort covers permission
+ *requests*; it is not a sandbox." The PR #131 review independently confirmed the same shape at
+ the fourth host: Hermes's headless `-z` mode sets `HERMES_YOLO_MODE=1` by its own documented
+ contract, so there is no permission event to intercept and no `permission_required` result to
+ return at all. Four hosts surveyed, zero that isolate extension code from the process trusting
+ it β that is a precedent for the opposite of a fifth precedent, not for one.
+2. **A typed capability cap is not the same thing as an enforced one, and this repository already
+ has the near-miss on record.** The review's own item 6 (Β§ "The amended gate list" below) found
+ that `src/lib/execution/adapters.mjs`'s routable-host invariant threw at **import time** for a
+ registry-only change β a schema-correct, fully first-party host flip could have bricked `ak run`
+ for every consumer before the corresponding execution adapter shipped. If a typed invariant on
+ ak's own built-in registry needed a maintainer's line-by-line review to catch, the same class of
+ cap expressed as a boolean field in a third party's manifest β `canBePrimary: false`,
+ self-declared, in code that party also controls β is not a safe place to put the only enforcement
+ of "this adapter may never become primary." A capability cap is only as strong as the code that
+ is *unable* to ignore it, and in-process code sharing ak's own call stack is never in that
+ position by construction.
+3. **The integrity primitives this contract adopts instead already exist, tested, in the tools this
+ review examined.** Codex CLI's trust model for a registered MCP server pins a content hash at
+ approval time and invalidates that approval the instant the pinned content changes β never
+ re-approving silently on drift. Hermes draws the same line one level lower: interactive
+ `mcp add` requires an explicit confirmation before registering a server, contrasted directly
+ against `-z`'s blanket, event-free auto-approval the review flagged as the honest-disclosure
+ case in ADR-0030's own posture. Both are **consent gates outside the code being trusted**, not
+ capability fields inside it. That is the shape this ADR adopts: hash-pinned consent on a
+ manifest, invalidated the instant the manifest's content changes, enforced by `ak`'s own loader β
+ never by the adapter's own declaration.
+
+The accepted mechanism is therefore: **a declarative manifest, driving a fixed set of consented
+subprocess hooks. No third-party code ever executes inside the `ak` process.** Everything the
+manifest can express is data; everything the adapter *does* happens in a spawned process ak owns
+the lifecycle of, exactly the way ADR-0016 Β§3 already bounds built-in projection network/process
+probes and ADR-0018 already bounds worker subprocess termination proof.
+
+## Decision
+
+### 1. The manifest (contract: 1)
+
+`kit.json` gains a `hostAdapters` array. Each entry names a manifest file, never a module:
+
+```json
+{
+ "hostAdapters": [
+ {
+ "name": "hermes",
+ "source": "~/.config/ak/adapters/hermes.json",
+ "contract": 1
+ }
+ ]
+}
+```
+
+The manifest itself is JSON: a host descriptor and capability block shaped like `HostAdapter` in
+ADR-0016 Β§1 (minus the three fields capped in Β§3 below), plus a `hooks` block. Each hook names one
+lifecycle or execution verb (`detect`, `plan`, `apply`, `verify`, `undo`, and the execution-adapter
+methods when `capabilities.activityRouting` is declared) and the subprocess command that implements
+it. There is no function-valued field anywhere in the manifest β every value is JSON-serializable,
+which is itself part of the safety property: a manifest that cannot express a closure cannot smuggle
+one in.
+
+### 2. Driving-surface vocabulary: `cli-subprocess` / `acp` / `mcp`
+
+Every declared hook names the driving surface that invokes it. Contract v1 recognizes three surface
+names:
+
+- **`cli-subprocess`** β spawn the declared binary/script as a subprocess, apply the same bounded
+ timeout and honest-unreachable discipline ADR-0016 Β§3 already requires of built-in projection
+ probes, and read back either a structured JSON result (the `normalizedFacts`/`WorkerResult` shape)
+ or a plain-text summary capture (the generic sibling of `createJsonlSummaryCapture`, needed for a
+ host like Hermes that has no structured run mode). This is the **only surface with a working
+ implementation in this wave.**
+- **`acp`** β Agent Client Protocol invocation. Named for forward compatibility with driving
+ surfaces this contract anticipates but has not built.
+- **`mcp`** β Model Context Protocol tool call, the same shape this session's own ClaudeβCodex
+ bridge uses to drive one host from another. Also named, also not implemented.
+
+An adapter manifest declaring `acp` or `mcp` today fails admission with an explicit "surface not yet
+supported" diagnostic. It is never silently downgraded to `cli-subprocess` β a silent downgrade
+would run a hook the adapter author never tested against that surface, exactly the kind of guessed
+success ADR-0016 Β§5 and ADR-0023 already forbid elsewhere.
+
+### 3. Capability caps are schema-structural, not runtime-checked
+
+`canBePrimary`, `aqeProvider`, and `commandStatusline` are not fields the external-adapter manifest
+schema accepts, at any value. An adapter author cannot express the claim "I am primary-eligible" β
+not because `ak` reads and rejects `true`, but because the accepted JSON Schema has no place to put
+it. This is the direct fix for the gap identified in "Why the mechanism changed" above: a capability
+cap enforced by field-absence cannot be bypassed by anything the adapter's own code does, because
+there is no code β only a document a schema either accepts or rejects before anything runs.
+`canRouteActivities` remains expressible; it is the one capability an external adapter may claim,
+matching the shape OpenCode already occupies as a non-primary, non-AQE, routable host.
+
+### 4. Admission: fail-closed, per-adapter isolated, behind an experimental flag
+
+The entire surface is inert unless `AK_EXPERIMENTAL_HOST_ADAPTERS=1` is set in the environment. With
+the flag unset, a `hostAdapters` entry in `kit.json` is parsed and preserved (never silently
+dropped) but never admitted β `ak` reports it present-but-inactive rather than pretending it does
+not exist.
+
+With the flag set, admission of each declared adapter is independent:
+
+- schema validation of the manifest, hash verification against the persisted `trust.hash`, and
+ hook-surface support are all checked before any hook of that adapter ever runs;
+- a failure at any of those steps is reported and that one adapter is skipped β it never brings down
+ `ak status`, `ak sync`, or any built-in host's handling;
+- first-party registries keep throwing at construction. A broken built-in is a build error; a broken
+ external adapter is a diagnosed, isolated fact, per ADR-0023's fail-closed-and-explicit posture
+ applied to a source ak did not author.
+
+### 5. The admitted overlay
+
+An admitted external host is exposed through an overlay alongside `HOST_REGISTRY`, never by
+mutating the frozen built-in registry itself β the same non-negotiable ADR-0016 Β§1 drew around
+compatibility exports ("derived⦠but not independent sources of truth"), generalized to a second,
+explicitly consented source instead of legacy-compat alone. Every consumer that already derives its
+behavior from capability lookups over the registry (ADR-0016 Β§2: host selection, primary-host
+eligibility, activity routing, install/MCP/guidance/status-line/transcript/usage work, verification)
+picks up an admitted host automatically, with no adapter-specific branch to add. An admitted host is
+invisible as a special case to any consumer that was already capability-driven β and visible only
+where a consumer still hardcodes `claude`/`codex`/`opencode` by name. That residue is exactly the
+amended gate list below.
+
+### 6. Consent: hash-pinned, edit-invalidated
+
+Registering an adapter computes a content hash over the manifest and every declared subprocess hook
+command, discloses the full manifest through the same trust-manifest surface ADR-0018/ADR-0023
+already use before any other mutation, and requires explicit confirmation before persisting
+`trust.hash` and `trust.consentedAt`. Every subsequent load re-hashes the manifest and compares: a
+mismatch means the manifest changed since consent, and the adapter is **not admitted** until
+re-consented. This is Codex's pin-and-invalidate model, applied to `ak`'s own adapter manifests
+instead of Codex's MCP servers β consent lives outside the trusted boundary, attached to a specific
+byte sequence, never to an identity that content can silently drift underneath.
+
+### 7. No in-process third-party code, ever
+
+Every hook β lifecycle and execution alike β is a subprocess `ak` spawns and owns the termination
+proof of, on the same bounded-timeout, honest-diagnostics terms ADR-0016 Β§3 and ADR-0018's worker
+cleanup contract already hold built-in projections and workers to. `ak` never calls `import()` or
+`require()` on a path an adapter supplies, and an adapter's existence adds no npm dependency to
+`@pacphi/agentic-kit` β the package remains zero-runtime-dependency regardless of how many external
+adapters a user registers.
+
+### 8. The amended gate list
+
+The maintainer's PR #131 review found the proposal's in-tree gate list (its Β§8) materially
+incomplete for the in-process shape, and asked that any accepted design carry the complete list.
+The mechanism changed, but every named gap is a real, mechanism-independent seam in how `ak` treats
+"the host set" today, so all six remain load-bearing for the subprocess-hook design too. Status as
+observed in this worktree:
+
+| # | Gap (maintainer's review) | Disposition |
+|---|---|---|
+| 1 | **The import-time invariant.** `execution/adapters.mjs` enforced a *bidirectional* built-inβroutable-hosts check at import; a registry-only host flip to `canRouteActivities: true` ahead of its adapter landing would brick `ak run` for everyone. | **Done, wave 1.** `assertBuiltinAdaptersRoutable` now checks only the built-inβregistry direction (an in-tree wiring mistake still throws at import, exactly as before); the reverse direction is a merge seam β `executionAdapterFor()` returns `null` and the runner degrades that one worker with `cli_unavailable`, per [ADR-0019](0019-escalation-in-ak-run.md)'s existing degradation precedent for a host with no adapter (`src/lib/execution/adapters.mjs`). |
+| 2 | **Uninstall-through-undo.** `ak uninstall` bypassed `runLifecycle` entirely; a third-party footprint would persist forever. | **Done, wave 1.** `uninstall.mjs` now runs registry-driven teardown for `hostsWithLifecycle()`, calling each host's own `undo` through `runLifecycle` β opencode-specific dispatch is gone; an admitted external host's `undo` hook runs on the identical path (`src/commands/uninstall.mjs`). |
+| 3 | **Permission authorization by host.** Setup's permission pass authorized only claude-host rules; `removeUndisclosedPermissions` would strip a non-claude host's permissions and fail setup. | **Done, wave 1.** `projectPermissionManifest` now unions the authorized auto-approve set across every *enabled* host's trust manifest (a host not gating on enablement always contributes; an opt-in host contributes once `cfg` enables it) instead of hardcoding claude's rules, with `hosts` injectable for tests (`src/commands/setup.mjs`). |
+| 4 | **Attribution-surfaces policy.** Three surfaces handled an unrecognized host differently and by accident: the usage scorecard collapsed it into `claude`, live sessions rewrote it to `'internal'`, and qe-court's `vendorOf` mapped it to a shared `'unknown'` β each capable of distorting a count in either direction. | **Done, Phase 0.** All three now exclude-and-label instead of silently collapsing: qe-court gives every unregistered id its own `unregistered:` tag and excludes unregistered vendors from the diversity count entirely (`src/lib/qeCourt.mjs`); live events pass a safely-shaped unknown host id through verbatim and bucket anything unsafe as the explicit `'unknown-host'` (`src/lib/live/event-schema.mjs`); the usage scorecard buckets by `host ?? 'unknown'` rather than defaulting to `claude` (`src/lib/usage-index.mjs`). |
+| 5 | **Unknown-key warning.** `kit.json` silently round-trips unknown top-level keys, so `hostAdapters` on an ak version that predates this ADR is a silent no-op. | **Done, wave 1** (as a general mechanism, not one written for `hostAdapters` specifically). `loadKitConfig` now warns once per process per distinct unknown-key set β "kit.json keys not recognized by this ak version: β¦, preserved, ignored" β for *any* key the running version doesn't know, `hostAdapters` included on an older `ak` (`src/lib/config.mjs`). |
+| 6 | **Test pins.** ~12 assertions pinned the built-in host set directly, and the test-enforced registryβdirectory parity meant a registered host with no authored About card failed CI. | **Working (wave 3, merged #148).** Host-set and default-map expectations across nine test files now derive from `managedHostIds()`/`routableHostIds()`/`primaryHostIds()`/`defaultHostMap()`, scoped to built-ins; the component-directory parity invariant is stated built-in-scoped, exempting admitted adapters until this contract graduates them. |
+
+## Supersession of ADR-0016's closed-registry clause
+
+ADR-0016 states, as a design requirement:
+
+> Agentic-kit remains a plain-ESM, zero-runtime-dependency CLI. Its normal operation is
+> offline-first. The design must therefore be a closed, validated registry of built-in code, not
+> an arbitrary third-party plugin runtime.
+
+and restates it in its non-goals:
+
+> It does not implement every provider, a public adapter SDK, or arbitrary third-party code
+> loading.
+
+**This ADR formally supersedes that clause.** The registry is no longer exclusively built-in code:
+an explicitly registered, hash-pinned, subprocess-only external adapter may be admitted into an
+overlay alongside it, gated by `AK_EXPERIMENTAL_HOST_ADAPTERS=1`. Every other requirement that
+clause was protecting β zero-runtime-dependency, offline-first normal operation, no dynamically
+imported third-party code inside the `ak` process β remains intact and is in fact the specific
+design this ADR uses to satisfy the request without reopening either property. ADR-0016 Β§1's
+sentence "Agentic-kit does not dynamically import adapter paths from `kit.json`, npm packages, or
+user directories" is superseded in the same, narrow sense: `kit.json` may now name an adapter
+manifest, but nothing under that name is ever `import()`-ed β the clause's *intent* (no in-process
+third-party code) is preserved even as its literal *mechanism description* (no `kit.json`-driven
+external reference of any kind) is not.
+
+A matching one-line update-note has been added to ADR-0016 itself, pointing here.
+
+## Non-goals
+
+- **No marketplace, discovery, or naming-convention scan.** Admission is always explicit
+ registration by manifest path; there is no `ak-host-*` npm-tree scan, ever β that is exactly the
+ fail-open pattern ADR-0023 exists to prevent, and PR #131's own proposal already rejected it for
+ the in-process shape.
+- **No code signing.** Consent is a hash pin plus explicit user confirmation, not a signature chain
+ or a claim of provenance beyond "this is the content the user agreed to run."
+- **No in-process plugin API, now or as a later contract version.** Contract v1's ban on `import()`
+ of adapter-supplied code is not a bootstrapping restriction to be lifted once the mechanism
+ matures; it is the property "Why the mechanism changed" argues for. A future contract version may
+ add driving surfaces (`acp`, `mcp`) or hook verbs; it may not add in-process code execution.
+- **No AQE projection, ever.** An admitted external host cannot become an `aqeProvider` at any
+ contract version β that field is schema-absent for the same reason `canBePrimary` and
+ `commandStatusline` are (Β§3), and AQE's own provider set is upstream's enumeration, not one `ak`
+ extends by admitting a host ([ADR-0028](0028-local-openai-compatible-providers.md) draws the
+ identical line for `local-openai`).
+- **No automatic routing or seeding.** An admitted host is never auto-seeded into `routing.routes`;
+ it must be explicitly routed, matching [ADR-0018](0018-generalized-host-worker-execution.md)'s
+ existing rule that automatic seeding stays Claude/Codex subscription-only.
+- **No subprocess/RPC protocol beyond the three named driving surfaces**, and no promise that `acp`
+ or `mcp` ship in this contract version. Re-specifying two lifecycles as a wire protocol is a
+ larger commitment than this ADR's evidence currently justifies.
+- **No vendoring of any specific third-party adapter into this repository**, and no obligation that
+ agentic-kit maintainers verify, support, or track the release cadence of any host an adapter
+ targets. An adapter author owns their host's correctness; `ak` owns only the contract.
+
+## Consequences
+
+- A fourth (fifth, β¦) host CLI can be described to `ak` without a dedicated ADR, owner module, or
+ maintainer commitment to a CLI they may not run β at the cost of everything that host can express
+ being strictly less than a built-in: no in-process code, three capped fields, and admission gated
+ behind an explicit flag and an explicit content-hash consent.
+- The gap the amended gate list closes was real independent of this ADR: items 1β5 harden the
+ built-in host set's own correctness (a registry-only routable-host flip, uninstall completeness,
+ permission authorization, attribution honesty, and unknown-key visibility) whether or not any
+ external adapter is ever registered. Publishing the extension point is what surfaced them; keeping
+ them fixed does not depend on the extension point staying enabled.
+- `AK_EXPERIMENTAL_HOST_ADAPTERS` being unset is the default, and the default behavior of every
+ existing command is unchanged: an unset flag makes the whole surface inert, and a `hostAdapters`
+ key with the flag unset round-trips through `kit.json` untouched.
+- A capability an external adapter cannot express (`canBePrimary`, `aqeProvider`,
+ `commandStatusline`) is a permanent property of contract v1, not a temporary restriction lifted at
+ graduation β graduation (below) freezes the *contract*, not the caps.
+
+## Self-graded implementation status
+
+Dated 2026-08-15, at Wave 4 doc-authoring time. Rows already covered by the amended gate list are
+not repeated; this table grades the mechanism this ADR newly decides β the manifest, admission,
+overlay, hook-runner, and consent β none of which existed as code prior to this wave. Grades:
+**Working** (implemented and tested in this worktree), **Demo** (implemented, not yet under test),
+**TBD** (not yet implemented as of this dating). Per the model set by ruflo's own ADR-015-v2
+practice β a self-graded status table an ADR carries at acceptance time, filled in with real
+evidence as implementation lands rather than promised in prose β the lead fills in test counts and
+flips remaining TBD cells at Wave 4 integration.
+
+| Mechanism | Grade (2026-08-15) | Evidence |
+|---|---|---|
+| Manifest schema (contract: 1) | TBD | Owned by the sibling contract work package; no schema module present in this worktree as of this dating. |
+| Admission gate (fail-closed, per-adapter isolated) | TBD | Owned by the sibling contract work package; not present in this worktree as of this dating. |
+| `AK_EXPERIMENTAL_HOST_ADAPTERS` flag gating | TBD | Not present in this worktree as of this dating; no occurrences found under `src/`. |
+| Admitted-host overlay (registry-adjacent, non-mutating) | TBD | Depends on the admission gate landing first. |
+| Subprocess hook-runner (`cli-subprocess` surface) | TBD | Owned by the sibling hook work package; not present in this worktree as of this dating. |
+| Hash-pinned consent + edit-invalidation | TBD | Owned by the sibling hook work package; not present in this worktree as of this dating. |
+| Capability-cap schema absence (Β§3) | TBD | Depends on the manifest schema landing first. |
+| Gate item 1 β import-time invariant | **Working** | `assertBuiltinAdaptersRoutable`, one-directional since W1-B (`src/lib/execution/adapters.mjs`). |
+| Gate item 2 β uninstall-through-undo | **Working** | Registry-driven `hostsWithLifecycle()` teardown loop (`src/commands/uninstall.mjs`). |
+| Gate item 3 β permission authorization by host | **Working** | `projectPermissionManifest` union-across-enabled-hosts, F-04 (`src/commands/setup.mjs`). |
+| Gate item 4 β attribution surfaces policy | **Working** | F-11 qe-court tagging (`src/lib/qeCourt.mjs`); `resolveHost` safe-passthrough/`unknown-host` (`src/lib/live/event-schema.mjs`); `host ?? 'unknown'` bucketing (`src/lib/usage-index.mjs`). |
+| Gate item 5 β unknown-key warning | **Working** | F-14 `warnUnknownTopLevelKeys` (`src/lib/config.mjs`). |
+| Gate item 6 β test pins (registryβdirectory parity) | Working (wave 3, #148) | Registry-derived, built-in-scoped. |
+
+## Graduation gate
+
+Contract v1 is **experimental** for the life of the `4.0.0-alpha.*` line and remains so afterward
+until it explicitly graduates. Graduation is a written, falsifiable event, not a vibe:
+
+1. A **real external adapter** β maintained outside this repository, by someone other than an
+ agentic-kit maintainer β passes the full conformance kit (the same lifecycle, execution,
+ ownership, and `normalizedFacts` conformance tests a built-in host is held to, run against the
+ external adapter's declared hooks).
+2. That adapter then completes **one full release's worth of soak** in the field with no
+ contract-shape change required to keep it working.
+3. Only once both hold does the manifest **contract integer** freeze: `contract: 1` becomes a
+ guaranteed-stable shape, and any subsequent breaking change to the manifest, hook verbs, or
+ driving-surface vocabulary ships as `contract: 2`, admitted alongside `contract: 1` rather than
+ replacing it outright.
+
+Before graduation, `contract: 1` may change between alpha releases without a version bump. That
+instability is bounded, not open-ended: it exists to let the first real adapter's conformance run
+surface shape problems cheaply, the same way carrying Hermes surfaced one widening and one guard for
+the in-process proposal's own subprocess base. It is not a license to redesign the contract
+speculatively.
+
+## References
+
+- `src/lib/adapters/registries.mjs` (`HOST_REGISTRY`, `validateHostAdapter`, `validateRegistries`),
+ `src/lib/adapters/lifecycle.mjs` / `lifecycle-registry.mjs` (`validateLifecycleAdapter`,
+ `registerBuiltinLifecycle`), `src/lib/execution/adapters.mjs`
+ (`assertBuiltinAdaptersRoutable`, the merge seam), `src/commands/uninstall.mjs`
+ (`hostsWithLifecycle()` teardown loop), `src/commands/setup.mjs`
+ (`projectPermissionManifest`, `removeUndisclosedPermissions`), `src/lib/config.mjs`
+ (`KNOWN_TOP_LEVEL_KEYS`, `warnUnknownTopLevelKeys`), `src/lib/qeCourt.mjs` (F-11 unregistered-id
+ tagging), `src/lib/live/event-schema.mjs` (`resolveHost`, `SAFE_HOST_ID`), `src/lib/usage-index.mjs`
+ (host bucketing).
+- [ADR-0016](0016-capability-driven-integration-adapters.md) β the closed-registry clause this ADR
+ supersedes, and every capability/lifecycle/ownership/provenance contract this ADR reuses without
+ change.
+- [ADR-0018](0018-generalized-host-worker-execution.md) β the trust-boundary section this ADR's
+ "no host sandboxes extension code" evidence line rests on, and the worker termination-proof
+ discipline the subprocess hook-runner inherits.
+- [ADR-0019](0019-escalation-in-ak-run.md) β the `cli_unavailable` degradation precedent the
+ import-time invariant fix (gate item 1) generalizes.
+- [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md) β the fail-closed-and-honest
+ posture the admission gate and the ban on naming-convention discovery both apply.
+- [ADR-0028](0028-local-openai-compatible-providers.md) β the sibling ADR accepted from the same PR
+ #131, and the precedent for "accepted with corrections after review," which this ADR follows in
+ house style.
+- `docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md` β the companion document PR #131 shipped alongside its
+ own draft of this ADR number; Β§Β§1β2 and Β§5 of that document remain accurate background even though
+ Β§3's in-process mechanism is not what this ADR accepts.
+- [PR #131](https://github.com/pacphi/agentic-kit/pull/131) β original proposal and its maintainer
+ review comment, the source of the amended gate list and the Hermes-behavior findings cited above.
diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md
new file mode 100644
index 0000000..c912d9a
--- /dev/null
+++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md
@@ -0,0 +1,191 @@
+# ADR-0031 β Capability graduation: earned parity for external host adapters, and the upstream request path
+
+- **Status:** Accepted (governance decision; implementation staged)
+- **Date:** 2026-08-16
+- **Deciders:** agentic-kit maintainers
+- **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md),
+ [ADR-0018](0018-generalized-host-worker-execution.md),
+ [ADR-0019](0019-escalation-in-ak-run.md),
+ [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md),
+ [ADR-0029](0029-host-adapter-extension-point.md) (amends its "permanent caps" framing β see
+ [Amendment](#amendment-to-adr-0029))
+- **Amends:** ADR-0029, on one point only: the three capability caps are reframed from *permanent*
+ to *not self-declarable, but earnable*.
+
+## Context
+
+[ADR-0029](0029-host-adapter-extension-point.md) admitted external host adapters as a declarative
+manifest plus consented, subprocess-only hooks, behind `AK_EXPERIMENTAL_HOST_ADAPTERS=1`. To make
+the door safe, three capabilities were made **inexpressible** in the manifest schema β
+`canBePrimary`, `aqeProvider`, and `commandStatusline` β and ADR-0029 described that block as
+permanent.
+
+Two things push past that framing:
+
+1. **The product intent is full parity.** A host that clears conformance should be able to
+ participate exactly like a built-in β lead a run, own a status line, be accounted for in
+ quality β not sit permanently behind a glass wall. "Second-class forever" is not the goal;
+ "earn your way to first-class" is.
+
+2. **`ak` is downstream of two other systems.** [ruflo](https://github.com/ruvnet/ruflo)
+ orchestrates the agent loop and [agentic-qe](https://github.com/proffesor-for-testing/agentic-qe)
+ runs quality. Some parity ceilings are genuinely not `ak`'s to lift β they live upstream β and a
+ contributor chasing a conformance tier needs a real route to express what's missing, not a dead
+ end.
+
+This ADR resolves both. It keeps every safety property ADR-0029 established and adds the governance
+model that turns the caps from a wall into a ladder.
+
+## Decision
+
+### 1. Earned, never self-declared
+
+The manifest schema stays a **strict allow-list in which a capped capability is inexpressible**. An
+adapter can never *write down* that it is primary, an AQE provider, or a command-statusline owner.
+This is the safety invariant from ADR-0029 and it is permanent: self-declaration is the attack
+surface, so it stays closed forever.
+
+Parity comes through a **separate channel**. A capability is *earned* by passing a conformance tier
+and *granted* by the maintainer β recorded as a hash-pinned **capability grant** (the same
+edit-invalidation model as adapter consent), never as a field in the adapter's own manifest. The
+adapter never asserts the capability; conformance evidence plus an explicit maintainer grant confers
+it. `hostTierLabel()` and the registry already render behaviour from capabilities, so a granted
+capability lights up at every call site without special-casing.
+
+### 2. Tiered conformance
+
+Conformance becomes tiered rather than pass/fail. Each tier is a black-box test set β spawn the real
+host, assert the real behaviour, against an installed layout β that gates one capability:
+
+| Tier | Gates | Evidence shape |
+| ---- | ----- | -------------- |
+| `admission` | Registration through the fail-closed gate (ADR-0029, shipped) | manifest validates, admits, hooks run |
+| `session-driving` | `canDriveSession` β the host actually drives an interactive/oneshot session | a real session completes and is observed |
+| `activity-routing` | `canRouteActivities` β the host runs a supervised `ak run` worker to a structured result | a worker completes under the runner's contract (ADR-0018) |
+| `primary-eligible` | Grants `canBePrimary` β the host can *lead*: anchor routing, be escalated toward | leads a run and receives an escalation, per ADR-0019 |
+| `statusline` | Grants `commandStatusline` β renders a command-backed footer through supervised hooks | a footer renders and refreshes |
+
+Passing a tier records evidence; the maintainer's grant turns evidence into capability. A tier the
+adapter cannot meet because the capability is upstream is marked **gated** (Β§4), not failed.
+
+### 3. Two graduation destinations
+
+A conformed adapter lands in one of two places, the maintainer's call:
+
+- **Blessed external adapter** β added to a curated, hash-pinned list. It stays out-of-tree and
+ experimental, holding exactly the capabilities its tiers earned. Right for niche or long-tail
+ hosts the project does not want to own.
+- **Promoted built-in** β its host descriptor is adopted as a first-party registry entry. Because of
+ the registry-driven refactor delivered across Phases 0β2, this is a small, ordinary PR (a
+ registry entry, a lifecycle adapter, an About card). Once built-in, the caps no longer apply
+ *because it is now first-party code the maintainer vouches for* β that is what promotion means. Its
+ hook scripts either ship bundled or are reimplemented as in-process glue, the maintainer's choice.
+
+### 4. The upstream capability-request path
+
+When a conformance tier cannot be met, the first question is **whose capability is missing**:
+
+- **`ak`-local** (run execution, the trust CLI, remote manifest sources, quality-gate anchors) β the
+ project's to build. A normal `ak` change; no one to wait on.
+- **Upstream β agentic-qe** β being a recognized **AQE provider type** is not `ak`'s to grant.
+ agentic-qe's provider set is a closed, upstream-defined enumeration (verified against
+ `agentic-qe@3.13.10`: `ALL_PROVIDER_TYPES` plus a `createProvider` switch, extended only by an
+ upstream code change). The path: file a concrete capability request against agentic-qe (a
+ provider-plugin API), record the tier as `gated: agentic-qe#NNN`, and light it up when the upstream
+ release ships. *Interim:* quality still runs through the model provider underneath the host, so QE
+ is not blocked β only the host's own AQE identity is.
+- **Upstream β ruflo** β being a native ruflo **backend** (an `ENABLE_*` target that drives the loop)
+ is defined inside ruflo (grounded against `ruvnet/ruflo@45e65b5`: backend enablement is per-host
+ `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` / `ENABLE_GEMINI_MCP`, not an outside registration). The path:
+ request a documented backend-registration surface upstream. *Interim:* the host runs through `ak`'s
+ own supervised execution, just not as a ruflo-native backend.
+
+The maintainer champions the request upstream with a named extension point, tracks the gated tier so
+an adapter's status shows exactly what it waits on, and exposes the capability in the adapter contract
+once upstream releases it. This is what keeps "no limitations after conformance" honest: the
+limitations that *are* real get a documented, per-layer route to disappear.
+
+### 5. The contributor-to-built-in lifecycle
+
+Graduation runs on a fixed sequence. A contributor **authors** a manifest and hooks,
+**self-tests** against the conformance kit, and **publishes** β at which point any user may opt in
+behind the experimental flag with hash-pinned consent, needing nothing from the maintainer. To go
+further, the contributor **proposes** it with a conformance report; the maintainer **verifies** by
+re-running the conformance kit and reviewing the hook scripts (the only part that executes),
+**decides** the tier and destination (Β§3), and **releases**. Conformance is objective; the
+maintainer is the judge; trust rests on reproduced evidence plus a hook read, never on running the
+contributor's code inside `ak`.
+
+### 6. Freeze criteria
+
+`contract: 1` (ADR-0029) freezes and the experimental flag is dropped when a **real** external
+adapter (Hermes first) clears the full conformance kit and survives one release of soak. Graduation
+of a capability tier and the freeze of the contract are distinct: tiers can be earned while the
+contract is still experimental.
+
+## Amendment to ADR-0029
+
+ADR-0029 states the three caps are permanent. This ADR amends that: **the block on
+*self-declaration* is permanent; the *capability* is earnable** through a conformance tier and a
+maintainer grant (Β§1, Β§2). ADR-0029's schema, admission gate, consent model, and hook runner are
+unchanged β capability grants are additive and live outside the manifest. A matching update note is
+added to ADR-0029 pointing here.
+
+## Consequences
+
+- An external adapter has a documented, evidence-gated route to full parity, up to and including
+ shipping as a built-in β without ever loading third-party code into the `ak` process and without
+ ever letting a manifest self-assert a capability.
+- The maintainer's review burden is bounded and objective: reproduce a conformance report, read the
+ hooks, grant a tier. No new trust primitive beyond the hash-pinned grant.
+- Real upstream ceilings are neither hidden nor faked: they become tracked capability requests with
+ an honest interim behaviour and a light-up path.
+- `ak` positions itself as the integrator that *shapes* its substrates rather than only consuming
+ them β the upstream-request path is a first-class part of the model, not a footnote.
+
+## Implementation status
+
+Per the ADR discipline this repository adopted (a dated, self-graded table before an Accepted claim
+rests on delivery): the **governance decision** is accepted; the **machinery** is staged and mostly
+unbuilt. This table is the source of truth for what is real.
+
+| Piece | Status (2026-08-16) | Note |
+| ----- | ------------------- | ---- |
+| Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) |
+| `ak host adapters trust` CLI (records consent/grants) | **Proposed β not built** | Smallest next step; today admission refuses `consent-required` |
+| External execution (`ak run` drives an admitted host) | **Proposed β not built** | The seam is a comment-only lookup today |
+| External lifecycle execution wired into setup/sync/uninstall | **Proposed β not built** | Loops are built-in-scoped by design until generalized |
+| Tiered conformance harness (`session-driving` β¦ `statusline`) | **Proposed β not built** | Extends the single conformance kit |
+| Capability-grant store + promotion command | **Proposed β not built** | Hash-pinned, mirrors consent |
+| Remote manifest sources (npm / URL) + resolveβhash ordering | **Proposed β not built** | File-path manifests only today |
+| Upstream request tracking (`gated: #NNN` against a tier) | **Proposed β not built** | Needs a place to record per-tier gating |
+| A real external adapter (Hermes) clearing the kit β contract freeze | **Not started** | Freeze criterion (Β§6) |
+
+## Alternatives considered
+
+- **Keep the caps permanent (ADR-0029 as written).** Rejected: it makes external hosts second-class
+ forever and contradicts the product intent of full parity after conformance.
+- **Let the manifest self-declare capabilities, checked at runtime.** Rejected outright. This is
+ ruflo's own cautionary tale, surfaced in the research sweep behind this work: a fully typed
+ capability-permission system shipped with *zero runtime enforcement*. Runtime checks on a
+ self-asserted capability are exactly the honor-system trap the strict-allow-list schema exists to
+ avoid. Self-declaration stays inexpressible; capability comes from earned evidence plus an explicit
+ grant.
+- **Treat every ceiling as `ak`-local and build around upstream.** Rejected as dishonest and
+ unmaintainable: agentic-qe's provider enum and ruflo's backend model are upstream facts. Faking a
+ local shim (e.g. projecting an unknown host into agentic-qe's config) would fabricate an identity
+ the upstream tool never declared it understands. The upstream-request path (Β§4) is the honest
+ alternative.
+
+## References
+
+- ADR-0029 (the extension point, schema, admission, consent, hook runner) and its amendment above.
+- ADR-0018 (supervised worker contract), ADR-0019 (bounded escalation) β the substance of the
+ `activity-routing` and `primary-eligible` tiers.
+- Upstream facts grounded in a source-cited research sweep: `agentic-qe@3.13.10`
+ (`ALL_PROVIDER_TYPES`, the `createProvider` switch, the closed provider enum) and
+ `ruvnet/ruflo@45e65b5` (`ENABLE_*` backend model). Re-verify against upstream HEAD before filing an
+ actual capability request.
+- Companion explainer for consumers and implementers:
+ [`docs/HOST-EXTENSIBILITY-EXPLAINER.html`](../HOST-EXTENSIBILITY-EXPLAINER.html); design dossier:
+ [`docs/ADAPTER-CONTRACT-DOSSIER.html`](../ADAPTER-CONTRACT-DOSSIER.html).
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 4e39037..c6d2bb9 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -24,7 +24,7 @@ Consequences**, and cites the grounded source it rests on where relevant.
| [0013](0013-admin-build-security-signals-and-honest-reach.md) | Admin: build/security signals, an honest Reach panel, and a pagination fix | Accepted |
| [0014](0014-dashboard-auth-and-remediation.md) | Dashboard auth token, plus a security/quality remediation pass | Accepted |
| [0015](0015-managed-codex-native-statusline.md) | Manage Codex's native user-wide status line without claiming rich-renderer parity | Accepted |
-| [0016](0016-capability-driven-integration-adapters.md) | Capability-driven host, provider, binding, projection, and observability adapters | Accepted; compatibility amended |
+| [0016](0016-capability-driven-integration-adapters.md) | Capability-driven host, provider, binding, projection, and observability adapters | Accepted; compatibility amended; closed-registry clause superseded by 0029 |
| [0017](0017-opencode-host.md) | OpenCode as a managed, observable host through native surfaces | Accepted; compatibility amended |
| [0018](0018-generalized-host-worker-execution.md) | Generalized host-worker execution; `ak run` canonical | Accepted; compatibility amended |
| [0019](0019-escalation-in-ak-run.md) | Bounded per-worker escalation in `ak run` | Accepted; historical context closed |
@@ -36,6 +36,9 @@ Consequences**, and cites the grounded source it rests on where relevant.
| [0025](0025-machine-footprint-metrics.md) | Machine footprint: infrastructure metrics for install, runtime, storage, and catalog | Implemented |
| [0026](0026-about-component-directory.md) | About: a component directory that explains everything ak installs | Implemented |
| [0027](0027-shared-project-census.md) | One project census, four scopes, every count explains itself | Implemented |
+| [0028](0028-local-openai-compatible-providers.md) | One generic local OpenAI-compatible provider, not a vendor enumeration | Accepted |
+| [0029](0029-host-adapter-extension-point.md) | External host adapters: declarative manifest, subprocess hooks | Accepted (experimental contract) |
+| [0031](0031-capability-graduation-and-upstream-requests.md) | Capability graduation: earned parity for external adapters, and the upstream request path | Accepted (governance; implementation staged) |
Theme: ADRs **0001β0006** define **dual-host LLM routing and leadership** β how `ak` lets ruflo route
each development activity (architecture, implementation, testing, review, β¦) to the right host (Claude
@@ -187,3 +190,38 @@ it, while Intelligence folds a repo's sub-directories and throwaway agent worktr
identity because that is what a user picks β a distinction that was also a live bug, since keying
the picker off identity while listing directories made 7 of 24 rows unreachable. Counts that remain
different stay different, and say why.
+
+**0028** adds a second local provider, `local-openai`, because a local model is normally served as
+an OpenAI-compatible endpoint on loopback β MLX, LM Studio, `llama.cpp`, vLLM β and frequently under
+a name the *user* chose, which no vendor enumeration can cover; the registry previously knew only
+`ollama`. It deliberately claims less than `ollama` (no catalogue, no runtime probe, no digest),
+puts the runtime's identity in the binding's endpoint rather than in the provider id, and projects
+to `['ruflo', 'codex', 'opencode']` only β no `claude` (the row claims only the OpenAI-compatible
+transport) and no `aqe` (AQE's provider set is upstream's own enumeration; `ollama` is in it,
+`local-openai` deliberately is not). Proposed by community contributor adrianco in PR #131, accepted
+with a correction to the PR's quoted Hermes reference config, whose `api_mode: openai` is not a
+valid Hermes value.
+
+**0029** answers the same PR #131 with a different mechanism than it proposed. Where the PR asked
+for an in-process ESM module loaded by `import()`, this ADR accepts a declarative manifest driving
+a fixed set of consented, subprocess-only hooks β no third-party code ever runs inside the `ak`
+process, gated behind `AK_EXPERIMENTAL_HOST_ADAPTERS=1`, admitted only after a hash-pinned consent
+that invalidates the moment the manifest's content changes. `canBePrimary`, `aqeProvider`, and
+`commandStatusline` are not fields the manifest schema accepts at all, so those three obligations
+stay first-party by construction rather than by an adapter's own promise. It formally supersedes
+ADR-0016's closed-registry clause, folds in the maintainer's full PR #131 gate list (import-time
+routable-host invariant, uninstall-through-undo, permission authorization by host, attribution
+surfaces policy, and kit.json's unknown-key warning β landed in wave 1 and Phase 0; registryβdirectory
+test pins remain wave 3), and stays experimental until a real external adapter clears the
+conformance kit and a release of soak.
+
+**0031** amends 0029 on one point and adds the governance around it. The three capability caps
+(`canBePrimary`, `aqeProvider`, `commandStatusline`) stay *inexpressible* in the manifest β that
+block on self-declaration is permanent β but the capability itself becomes *earnable*: passing a
+conformance tier plus an explicit maintainer grant (hash-pinned, outside the manifest) confers it,
+up to and including promotion to a first-party built-in with full parity. It also records the
+upstream-request path: some ceilings are not `ak`'s to lift (being an AQE provider type is
+agentic-qe's closed enum; being a native ruflo backend is ruflo's `ENABLE_*` model), so those become
+tracked capability requests with honest interim behaviour rather than pretended support. Accepted as
+a governance decision; the machinery (the trust CLI, external execution, tiered conformance, the
+grant store) is staged and self-graded in the ADR's implementation-status table.
diff --git a/docs/ddd/component-directory.md b/docs/ddd/component-directory.md
index 458904f..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
@@ -161,8 +161,12 @@ takes the worst of the pair, so the quieter one's health cannot hide behind the
no endpoint, and a failed status join degrades chips to `unknown` without hiding cards.
3. **Prose never claims runtime state.** Installed/version/configured render exclusively as
chips fed by detection; the paragraph reads true on any machine.
-4. **Registryβdirectory parity is a test.** Every managed tool has exactly one entry; no entry
- exists for something ak neither installs nor configures.
+4. **Registryβdirectory parity is a test, scoped to built-in adapters.** Every managed tool
+ shipped in the built-in registry has exactly one entry; no entry exists for something ak
+ neither installs nor configures. There is no dynamic or third-party host concept yet, so
+ today "built-in" and "the registry" are the same set; an externally-admitted adapter is
+ exempt from this parity gate until the wave-4 adapter-extension contract graduates it to a
+ card-carrying citizen.
5. **Links are `https`, named-host, user-initiated**; the kit fetches none of them; all are
covered by the nightly external link sweep.
6. **Official marks only where genuinely official and already shipped**; everything else is an
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/setup.mjs b/src/commands/setup.mjs
index 7529604..a285216 100644
--- a/src/commands/setup.mjs
+++ b/src/commands/setup.mjs
@@ -12,8 +12,9 @@ import * as heal from '../lib/heal.mjs';
import { fixStatusline } from '../lib/statusline.mjs';
import { reconcileGuidance } from '../lib/blocks.mjs';
import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs';
-import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../lib/opencode.mjs';
+import { reconcileOpencodeGuidance } from '../lib/opencode.mjs';
import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
+import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs';
import { installedVersion } from '../lib/versions.mjs';
@@ -23,7 +24,7 @@ import { readJson, writeJsonWithBackup } from '../lib/settings.mjs';
import { withDb } from '../lib/sqlite.mjs';
import { findMemoryEntry } from '../lib/project-memory.mjs';
import {
- setupTrustManifest, trustChangesForHost, trustManifestLines,
+ setupTrustManifest, trustManifestLines,
} from '../lib/trust-manifest.mjs';
import * as paths from '../lib/paths.mjs';
import { ok, warn, fail, info, heading, bold, dim, reportOutcome } from '../lib/output.mjs';
@@ -91,19 +92,27 @@ const ask = async (q, dflt, yes) => {
return a === '' ? dflt : a.startsWith('y');
};
-export const PROJECT_PERMISSION_MANIFEST = Object.freeze(
- trustChangesForHost('claude', { kind: 'auto-approve' }).map((entry) => ({
- owner: entry.owner, rule: entry.value, effect: entry.effect,
- })),
-);
-
-export function projectPermissionManifest(cfg) {
- const claude = setupTrustManifest(cfg, { project: true })
- .find((group) => group.hostId === 'claude');
- return (claude?.changes ?? []).filter((entry) => entry.kind === 'auto-approve')
+// The authorized/disclosed auto-approve set is the UNION across every
+// enabled host's trust manifest, not claude's alone (F-04) β a host whose
+// auto-approve rules don't gate on enablement (requiresHostEnabled: false,
+// claude's posture today) always contributes; an opt-in host (opencode,
+// codex, or a future one) only contributes once cfg actually enables it.
+// `hosts` is injectable so tests can prove a second host's rule survives
+// removeUndisclosedPermissions through the same seam trust-manifest.test.mjs
+// uses for host-registry-construction tests.
+export function projectPermissionManifest(cfg, /** @type {{hosts?: any[]}} */ { hosts } = {}) {
+ const manifest = setupTrustManifest(cfg, { project: true, ...(hosts ? { hosts } : {}) });
+ return manifest.flatMap((group) => group.changes)
+ .filter((entry) => entry.kind === 'auto-approve')
.map((entry) => ({ owner: entry.owner, rule: entry.value, effect: entry.effect }));
}
+// The baseline (no non-default host enabled) authorized set β identical to
+// "claude's rules" today because every claude auto-approve change sets
+// requiresHostEnabled: false, but now derived through the same registry-
+// driven path as projectPermissionManifest rather than hardcoded to claude.
+export const PROJECT_PERMISSION_MANIFEST = Object.freeze(projectPermissionManifest({}));
+
export function discloseSetupTrust(cfg, { project = false } = {}) {
const manifest = setupTrustManifest(cfg, { project });
if (!manifest.length) return manifest;
@@ -204,35 +213,47 @@ export async function run_machine({ flags, pkgRoot, cfg }) {
}
}
- // 6b. opencode host wiring β config-file MCP + skills, lifecycle plugin,
- // converted agents, platform skill (opencode.mjs owns all of it). Only
- // when the CLI is actually present: a declined/failed install must not
- // leave a freshly-created config home behind (codex-review #4).
- if (cfg.integrations?.hosts?.opencode) {
- if (!(await have('opencode'))) {
- warn('opencode: enabled but CLI not installed β wiring skipped (re-run `ak sync` after installing opencode-ai)');
- } else {
- const lifecycle = await runLifecycle({
- adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'apply', cfg, options: { pkgRoot },
- });
- const stack = lifecycle.result;
- (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`);
- if (stack.oc.fatal) {
- warn(`opencode plugin/agents/skill/guidance skipped β ${stack.oc.detail}`);
- return false;
- }
- ok(`opencode plugin: ${stack.plugin.detail}`);
- ok(`opencode agents: ${stack.agents.detail}`);
- if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`);
- // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18)
- // β not on the next status-driven reconcile. Same shared reconcile pick
- // and off use, so every command converges guidance identically.
- const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true });
- ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`);
- // opencode loads config/plugins/MCP/agents once at startup β say so now,
- // or the user files "hooks don't work" issues (observed live).
- info('restart opencode to load the hooks + MCP servers (loaded once at startup)');
+ // 6b. host lifecycle wiring β config-file MCP + skills, lifecycle plugin,
+ // converted agents, platform skill (each adapter owns its own surfaces
+ // β opencode.mjs for opencode). Registry-driven: loops
+ // builtinHostsWithLifecycle() rather than naming opencode, so a second
+ // BUILT-IN lifecycle host needs no new branch here. Only when the CLI
+ // is actually present: a declined/failed install must not leave a
+ // freshly-created config home behind (codex-review #4). The result
+ // SHAPE consumed below (stack.oc/plugin/agents/skill) is still
+ // opencode's own β the lifecycle contract doesn't mandate a common
+ // `apply()` result shape across hosts. builtinHostsWithLifecycle()
+ // (not hostsWithLifecycle()) deliberately excludes admitted external
+ // hosts: this loop body is opencode-shaped, and external lifecycle
+ // execution graduates in a later wave alongside a shape-agnostic body
+ // (see lifecycle-registry.mjs's registerAdmittedLifecycle comment).
+ for (const hostId of builtinHostsWithLifecycle()) {
+ if (!cfg.integrations?.hosts?.[hostId]) continue;
+ if (!(await have(hostId))) {
+ const pkg = HOSTS.find((h) => h.id === hostId)?.pkg ?? hostId;
+ warn(`${hostId}: enabled but CLI not installed β wiring skipped (re-run \`ak sync\` after installing ${pkg})`);
+ continue;
+ }
+ const lifecycle = await runLifecycle({
+ adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot },
+ });
+ const stack = lifecycle.result;
+ (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`);
+ if (stack.oc.fatal) {
+ warn(`opencode plugin/agents/skill/guidance skipped β ${stack.oc.detail}`);
+ return false;
}
+ ok(`opencode plugin: ${stack.plugin.detail}`);
+ ok(`opencode agents: ${stack.agents.detail}`);
+ if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`);
+ // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18)
+ // β not on the next status-driven reconcile. Same shared reconcile pick
+ // and off use, so every command converges guidance identically.
+ const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true });
+ ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`);
+ // opencode loads config/plugins/MCP/agents once at startup β say so now,
+ // or the user files "hooks don't work" issues (observed live).
+ info('restart opencode to load the hooks + MCP servers (loaded once at startup)');
}
// 7. frontier host hint β codex detected but not enabled (opt-in via `ak host pick`)
diff --git a/src/commands/status.mjs b/src/commands/status.mjs
index d757b59..3594881 100644
--- a/src/commands/status.mjs
+++ b/src/commands/status.mjs
@@ -24,6 +24,7 @@ import { coherence as adbCoherence } from '../lib/agentdb.mjs';
import { readJson } from '../lib/settings.mjs';
import { have } from '../lib/exec.mjs';
import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, hostAuthState, bothHostsEnabled, aqeRouterFile, aqeSupportsAgentOverrides, credentialGaps, collectIntegrationFacts } from '../lib/providers.mjs';
+import { PROVIDER_REGISTRY } from '../lib/adapters/index.mjs';
import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../lib/routing.mjs';
import { qeCourtShipped, readQeCourtConfig, validateCourtConfig } from '../lib/qeCourt.mjs';
import { drift as ruvectorDrift } from '../lib/ruvector.mjs';
@@ -57,6 +58,133 @@ Examples:
const row = (subsystem, level, message, fix = null) => ({ subsystem, level, message, fix });
+// Per-host status DETAIL rows β beyond the generic install/auth rows the
+// `hosts` loop in collect() already renders from facts for every host alike.
+// A detail renderer owns everything specific to how ONE host proves itself
+// wired (config files, lifecycle bridges, converted artifacts, β¦); the
+// opencode-specific PROBES/MESSAGES live in the renderer below and in
+// lib/opencode.mjs, never in the dispatch loop itself. Adding a fourth host
+// means adding (or not adding) a table entry here β the loop that walks this
+// table never changes.
+// The loop also passes `hostId`; this renderer doesn't need it (it IS the
+// opencode renderer) but the signature admits it so the dispatch call site
+// typechecks for every renderer uniformly.
+async function opencodeDetailRows({ cfg, pkgRoot, facts, hostId: _hostId = 'opencode' } = /** @type {any} */ ({})) {
+ const rows = [];
+ try {
+ if (!facts.hosts?.opencode?.present) {
+ rows.push(row('opencode', 'warn', 'enabled but opencode CLI not installed', 'sync installs opencode-ai (hosts step)'));
+ } else {
+ const source = catalogSource({ override: cfg.integrations?.ownership?.opencode?.catalogDir });
+ const st = opencodeMcpStatus(cfg);
+ const conv = st.parseError ? null : await opencodeConverged(cfg);
+ if (st.parseError) {
+ rows.push(row('opencode', 'warn',
+ 'opencode.json is not plain JSON (JSONC comments?) β ak refuses to touch it',
+ 'merge the ak wiring manually'));
+ } else if (!st.exists || !st.claudeFlow) {
+ rows.push(row('opencode', 'warn',
+ `opencode.json wiring incomplete (${[!st.exists ? 'no config file' : null, !st.claudeFlow ? 'claude-flow MCP missing' : null].filter(Boolean).join(', ')})`,
+ 'sync writes the opencode wiring'));
+ } else if (!conv?.converged) {
+ rows.push(row('opencode', 'warn',
+ `opencode.json wiring drifted (${(conv?.reasons ?? []).slice(0, 3).join('; ')}${(conv?.reasons?.length ?? 0) > 3 ? 'β¦' : ''})`,
+ 'sync re-applies the opencode wiring'));
+ } else {
+ rows.push(row('opencode', 'ok',
+ `opencode.json converged (claude-flow${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' β pre-existing (not ak-managed)'}`));
+ }
+ const receiptState = opencodeArtifactReceiptState(cfg.integrations?.ownership?.opencode?.managed);
+ const artifactReceipts = receiptState.receipts;
+ if (receiptState.adoptionBlocked) {
+ rows.push(row('opencode', 'warn',
+ 'artifact receipt ledger is malformed β ownership adoption blocked; artifacts left untouched',
+ 'repair integrations.ownership.opencode.managed.artifacts in kit.json or restore it from backup'));
+ }
+ const plug = pluginStatus({
+ pkgRoot, receipt: artifactReceipts.plugin,
+ adoptionBlocked: receiptState.adoptionBlocked,
+ });
+ if (!receiptState.adoptionBlocked && plug.adoptable) {
+ rows.push(row('opencode', 'warn',
+ 'lifecycle plugin is exact and marker-bearing but lacks an ownership receipt',
+ 'sync adopts it into the receipt ledger without rewriting it'));
+ } else if (!receiptState.adoptionBlocked && plug.foreign) {
+ rows.push(row('opencode', 'info', 'lifecycle plugin slot occupied by a user-owned ruflo-hooks.js β ak leaves it alone'));
+ } else if (!receiptState.adoptionBlocked && !plug.present) {
+ rows.push(row('opencode', 'warn', 'lifecycle plugin (ruflo-hooks.js) not deployed', 'sync deploys it'));
+ } else if (!receiptState.adoptionBlocked && !plug.current) {
+ rows.push(row('opencode', 'warn', 'lifecycle plugin out of date', 'sync rewrites it'));
+ }
+ const ag = agentsStatus({
+ source, receipts: artifactReceipts.agents,
+ stampReceipt: artifactReceipts.agentStamp,
+ adoptionBlocked: receiptState.agentsAdoptionBlocked,
+ });
+ if (!receiptState.adoptionBlocked && ag.adoptable) {
+ rows.push(row('opencode', 'warn',
+ `${ag.count} exact marker-bearing agents or stamp lack ownership receipts`,
+ 'sync adopts them into the receipt ledger without rewriting them'));
+ } else if (!receiptState.adoptionBlocked && ag.count === 0 && !source) {
+ rows.push(row('opencode', 'warn', 'no ruflo catalog source (marketplace clone or @claude-flow/cli)', 'install ruflo (or claude marketplace) for the agent catalog'));
+ } else if (!receiptState.adoptionBlocked && ag.count === 0) {
+ rows.push(row('opencode', 'warn', 'no converted ruflo agents', 'sync converts the ruflo agent set'));
+ } else if (!receiptState.adoptionBlocked && ag.modified) {
+ rows.push(row('opencode', 'info',
+ `${ag.count} converted agents include user edits β ak leaves those files alone`));
+ } else if (!receiptState.adoptionBlocked && ag.stale) {
+ rows.push(row('opencode', 'warn',
+ `${ag.count} agents from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`,
+ 'sync re-converts the agent set'));
+ } else if (!receiptState.adoptionBlocked) {
+ rows.push(row('opencode', 'ok', `${ag.count} converted agents (${ag.currentId})`));
+ }
+ const sk = skillStatus({
+ source, receipt: artifactReceipts.skill,
+ adoptionBlocked: receiptState.adoptionBlocked,
+ });
+ if (!receiptState.adoptionBlocked && sk.adoptable) {
+ rows.push(row('opencode', 'warn',
+ 'platform skill is exact and marker-bearing but lacks an ownership receipt',
+ 'sync adopts it into the receipt ledger without rewriting it'));
+ } else if (!receiptState.adoptionBlocked && sk.foreign) {
+ rows.push(row('opencode', 'info', 'skills/ruflo/SKILL.md is user-owned β ak leaves it alone'));
+ } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.present) {
+ rows.push(row('opencode', 'warn', 'platform skill (skills/ruflo/SKILL.md) not deployed', 'sync deploys it'));
+ } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.current) {
+ rows.push(row('opencode', 'warn', 'platform skill out of date', 'sync re-deploys it'));
+ }
+ }
+ } catch (e) {
+ rows.push(row('opencode', 'warn', `opencode check unavailable: ${e.message}`));
+ }
+ return rows;
+}
+
+// Dispatch table: host id β detail renderer. CONTRACT: a renderer must catch
+// its own errors and degrade to a warn row β the dispatch loop deliberately
+// has no catch, so an uncaught throw would take down ALL of collect(), not
+// just this host. A host absent from this table
+// gets no detail rows here (its install/auth state still comes from the
+// `hosts` loop, which is already host-neutral). This is the ONLY place a new
+// host's status detail wiring gets registered.
+const HOST_DETAIL_RENDERERS = { opencode: opencodeDetailRows };
+
+/** Host-neutral dispatch loop: walks `renderers` (defaults to the table
+ * above) and, for each host enabled in cfg, calls its renderer with the
+ * shared facts snapshot. Exported (not just used internally) so a test can
+ * prove a synthetic host renders through this exact loop β with no host-id
+ * branching anywhere in the loop body β by injecting its own renderers map
+ * instead of reaching into module internals. */
+export async function renderHostDetailRows({ cfg, pkgRoot, facts, renderers = HOST_DETAIL_RENDERERS }) {
+ const rows = [];
+ for (const [hostId, renderer] of Object.entries(renderers)) {
+ if (!cfg.integrations?.hosts?.[hostId]) continue;
+ rows.push(...(await renderer({ cfg, pkgRoot, facts, hostId })));
+ }
+ return rows;
+}
+
export async function collect({ pkgRoot, cwd = process.cwd() }) {
const rows = [];
const cfg = loadKitConfig();
@@ -399,100 +527,13 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
rows.push(row('codex-plugins', 'warn', `Codex plugin check unavailable: ${e.message}`));
}
- // opencode host wiring β the third host's counterpart of the codex-mcp rows:
- // opencode.json (mcp + skills.paths + permissions), the plugins/ lifecycle
- // bridge, the converted agent set, and the platform skill. Only surfaces when
- // the opencode host is enabled AND installed (enabled-but-absent is the
- // hosts row's story); probes are file reads + one bin check (codex-review #4).
- if (cfg.integrations?.hosts?.opencode) {
- try {
- if (!(await have('opencode'))) {
- rows.push(row('opencode', 'warn', 'enabled but opencode CLI not installed', 'sync installs opencode-ai (hosts step)'));
- } else {
- const source = catalogSource({ override: cfg.integrations?.ownership?.opencode?.catalogDir });
- const st = opencodeMcpStatus(cfg);
- const conv = st.parseError ? null : await opencodeConverged(cfg);
- if (st.parseError) {
- rows.push(row('opencode', 'warn',
- 'opencode.json is not plain JSON (JSONC comments?) β ak refuses to touch it',
- 'merge the ak wiring manually'));
- } else if (!st.exists || !st.claudeFlow) {
- rows.push(row('opencode', 'warn',
- `opencode.json wiring incomplete (${[!st.exists ? 'no config file' : null, !st.claudeFlow ? 'claude-flow MCP missing' : null].filter(Boolean).join(', ')})`,
- 'sync writes the opencode wiring'));
- } else if (!conv?.converged) {
- rows.push(row('opencode', 'warn',
- `opencode.json wiring drifted (${(conv?.reasons ?? []).slice(0, 3).join('; ')}${(conv?.reasons?.length ?? 0) > 3 ? 'β¦' : ''})`,
- 'sync re-applies the opencode wiring'));
- } else {
- rows.push(row('opencode', 'ok',
- `opencode.json converged (claude-flow${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' β pre-existing (not ak-managed)'}`));
- }
- const receiptState = opencodeArtifactReceiptState(cfg.integrations?.ownership?.opencode?.managed);
- const artifactReceipts = receiptState.receipts;
- if (receiptState.adoptionBlocked) {
- rows.push(row('opencode', 'warn',
- 'artifact receipt ledger is malformed β ownership adoption blocked; artifacts left untouched',
- 'repair integrations.ownership.opencode.managed.artifacts in kit.json or restore it from backup'));
- }
- const plug = pluginStatus({
- pkgRoot, receipt: artifactReceipts.plugin,
- adoptionBlocked: receiptState.adoptionBlocked,
- });
- if (!receiptState.adoptionBlocked && plug.adoptable) {
- rows.push(row('opencode', 'warn',
- 'lifecycle plugin is exact and marker-bearing but lacks an ownership receipt',
- 'sync adopts it into the receipt ledger without rewriting it'));
- } else if (!receiptState.adoptionBlocked && plug.foreign) {
- rows.push(row('opencode', 'info', 'lifecycle plugin slot occupied by a user-owned ruflo-hooks.js β ak leaves it alone'));
- } else if (!receiptState.adoptionBlocked && !plug.present) {
- rows.push(row('opencode', 'warn', 'lifecycle plugin (ruflo-hooks.js) not deployed', 'sync deploys it'));
- } else if (!receiptState.adoptionBlocked && !plug.current) {
- rows.push(row('opencode', 'warn', 'lifecycle plugin out of date', 'sync rewrites it'));
- }
- const ag = agentsStatus({
- source, receipts: artifactReceipts.agents,
- stampReceipt: artifactReceipts.agentStamp,
- adoptionBlocked: receiptState.agentsAdoptionBlocked,
- });
- if (!receiptState.adoptionBlocked && ag.adoptable) {
- rows.push(row('opencode', 'warn',
- `${ag.count} exact marker-bearing agents or stamp lack ownership receipts`,
- 'sync adopts them into the receipt ledger without rewriting them'));
- } else if (!receiptState.adoptionBlocked && ag.count === 0 && !source) {
- rows.push(row('opencode', 'warn', 'no ruflo catalog source (marketplace clone or @claude-flow/cli)', 'install ruflo (or claude marketplace) for the agent catalog'));
- } else if (!receiptState.adoptionBlocked && ag.count === 0) {
- rows.push(row('opencode', 'warn', 'no converted ruflo agents', 'sync converts the ruflo agent set'));
- } else if (!receiptState.adoptionBlocked && ag.modified) {
- rows.push(row('opencode', 'info',
- `${ag.count} converted agents include user edits β ak leaves those files alone`));
- } else if (!receiptState.adoptionBlocked && ag.stale) {
- rows.push(row('opencode', 'warn',
- `${ag.count} agents from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`,
- 'sync re-converts the agent set'));
- } else if (!receiptState.adoptionBlocked) {
- rows.push(row('opencode', 'ok', `${ag.count} converted agents (${ag.currentId})`));
- }
- const sk = skillStatus({
- source, receipt: artifactReceipts.skill,
- adoptionBlocked: receiptState.adoptionBlocked,
- });
- if (!receiptState.adoptionBlocked && sk.adoptable) {
- rows.push(row('opencode', 'warn',
- 'platform skill is exact and marker-bearing but lacks an ownership receipt',
- 'sync adopts it into the receipt ledger without rewriting it'));
- } else if (!receiptState.adoptionBlocked && sk.foreign) {
- rows.push(row('opencode', 'info', 'skills/ruflo/SKILL.md is user-owned β ak leaves it alone'));
- } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.present) {
- rows.push(row('opencode', 'warn', 'platform skill (skills/ruflo/SKILL.md) not deployed', 'sync deploys it'));
- } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.current) {
- rows.push(row('opencode', 'warn', 'platform skill out of date', 'sync re-deploys it'));
- }
- }
- } catch (e) {
- rows.push(row('opencode', 'warn', `opencode check unavailable: ${e.message}`));
- }
- }
+ // Per-host status DETAIL rows (opencode.json wiring, lifecycle bridge,
+ // converted agents, platform skill, β¦) β the host-neutral counterpart of
+ // the codex-mcp rows above. Dispatches through HOST_DETAIL_RENDERERS; only
+ // a host both enabled AND registered there produces rows (enabled-but-
+ // absent is the CLI-presence branch inside its own renderer, sourced from
+ // the shared facts snapshot β no extra probing here or in the loop).
+ rows.push(...(await renderHostDetailRows({ cfg, pkgRoot, facts: integrationFacts })));
// hosts (install-if-missing) β cheap: file read + `which`, no network.
// An enabled host that is entirely absent is installable by sync; an external
@@ -580,6 +621,19 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
}
}
}
+ // ADR-0028 F-29: local-openai is a local ($0) provider deliberately NOT
+ // projected to 'aqe' (unlike ollama, which is) β surface that asymmetry
+ // plainly so it reads as a fact, not a bug. Registry-driven (billing +
+ // projections), not an id check, so any future provider of the same
+ // shape gets the same treatment for free.
+ const providerById = Object.fromEntries(PROVIDER_REGISTRY.map((p) => [p.id, p]));
+ for (const binding of cfg.integrations?.bindings ?? []) {
+ const provider = providerById[binding.provider];
+ if (!provider || provider.billing !== 'local' || provider.projections.includes('aqe')) continue;
+ const endpoint = binding.endpoint ? ` @ ${binding.endpoint}` : '';
+ rows.push(row('providers', 'info',
+ `local binding: ${binding.provider} via ${binding.host}${endpoint} (${provider.billing} $0; not an AQE provider type)`));
+ }
} catch (e) {
rows.push(row('providers', 'warn', `provider check unavailable: ${e.message}`));
}
diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs
index 3af737a..9f44e46 100644
--- a/src/commands/sync.mjs
+++ b/src/commands/sync.mjs
@@ -8,8 +8,8 @@ import { have } from '../lib/exec.mjs';
import { fixStatusline, helperStampStale } from '../lib/statusline.mjs';
import { reconcileGuidance } from '../lib/blocks.mjs';
import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs';
-import { OPENCODE_LIFECYCLE_ADAPTER } from '../lib/opencode.mjs';
import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
+import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs';
@@ -188,23 +188,32 @@ export async function run({ flags, pkgRoot, fetchLatest }) {
// Runs BEFORE the blocks branch: the agents-opencode guidance target is gated
// on the config home this branch creates β this order lets a fresh enable
// converge guidance in the SAME sync (a second sync is then a true no-op).
- if (subsystems.has('opencode') && cfg.integrations?.hosts?.opencode) {
- if (!(await have('opencode'))) {
- info('opencode: enabled but CLI not installed β wiring skipped (hosts step installs it)');
- } else {
- const lifecycle = await runLifecycle({
- adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'apply', cfg, options: { pkgRoot },
- });
- const stack = lifecycle.result;
- // persist the markers on ANY refresh (a converged file whose kit.json
- // markers are stale/missing still needs the save, or the next teardown
- // cannot prove ownership β codex-review r3), not only on file changes.
- if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg);
- if (stack.oc.changed || !stack.oc.ok) report('opencode', stack.oc);
- report('opencode plugin', stack.plugin);
- report('opencode agents', stack.agents);
- if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill);
+ // Registry-driven: loops builtinHostsWithLifecycle() rather than naming
+ // opencode, so a second BUILT-IN lifecycle host needs no new branch here.
+ // Only opencode is registered today, so this loop runs exactly once β
+ // byte-identical to the single-host branch it replaces. The result SHAPE
+ // consumed below (stack.oc/plugin/agents/skill) is still opencode's own β
+ // the lifecycle contract doesn't mandate a common `apply()` result shape
+ // across hosts. builtinHostsWithLifecycle() (not hostsWithLifecycle())
+ // deliberately excludes admitted external hosts β see lifecycle-registry.mjs.
+ for (const hostId of builtinHostsWithLifecycle()) {
+ if (!subsystems.has(hostId) || !cfg.integrations?.hosts?.[hostId]) continue;
+ if (!(await have(hostId))) {
+ info(`${hostId}: enabled but CLI not installed β wiring skipped (hosts step installs it)`);
+ continue;
}
+ const lifecycle = await runLifecycle({
+ adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot },
+ });
+ const stack = lifecycle.result;
+ // persist the markers on ANY refresh (a converged file whose kit.json
+ // markers are stale/missing still needs the save, or the next teardown
+ // cannot prove ownership β codex-review r3), not only on file changes.
+ if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg);
+ if (stack.oc.changed || !stack.oc.ok) report('opencode', stack.oc);
+ report('opencode plugin', stack.plugin);
+ report('opencode agents', stack.agents);
+ if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill);
}
// The 'opencode' guard: the opencode branch above can CREATE the config home
// that activates the agents-opencode guidance target β a machine whose other
diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs
index bed193a..721e129 100644
--- a/src/commands/uninstall.mjs
+++ b/src/commands/uninstall.mjs
@@ -9,8 +9,9 @@ import readline from 'node:readline/promises';
import { run as runCmd } from '../lib/exec.mjs';
import { stripBlock, BEGIN, BUILTIN_BLOCKS } from '../lib/blocks.mjs';
import { unregister } from '../lib/mcp.mjs';
-import { retireOpencode } from '../lib/opencode.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
+import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
+import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
import { present as rbPresent } from '../lib/ruvnet-brain.mjs';
import * as paths from '../lib/paths.mjs';
import { ok, warn, info } from '../lib/output.mjs';
@@ -121,23 +122,40 @@ export async function run({ flags }) {
});
}
}
- {
- // cfg comes from the top of run() (read before any purge of kit.json).
- // --purge removes kit.json above; persisting cfg here would recreate it.
- if (cfg.integrations?.ownership?.opencode?.mcp === 'ak') {
- if (dry) info('[dry-run] stripped ak-managed opencode wiring + artifacts (opencode.json, plugin, agents, skill)');
- else {
- const ret = retireOpencode(cfg);
- ownershipTeardownOk = ret.ok;
- if (!flags.purge) saveKitConfig(cfg);
- (ret.ok ? ok : warn)(ret.ok
- ? 'stripped ak-managed opencode wiring + artifacts (opencode.json, plugin, agents, skill)'
- : `opencode teardown incomplete β ${ret.undo.detail}`);
- }
- } else if (fs.existsSync(paths.opencodeDir())) {
- // Not ak-managed (or never enabled): artifacts are still marker-gated, so
- // only ak-deployed files leave β user-owned agents/skills/plugins stay.
- act('removed ak-deployed opencode artifacts (plugin/agents/skill)', () => { retireOpencode(cfg); });
+ // Registry-driven host lifecycle teardown β reached by id, never by name
+ // (mirrors x/host.mjs's off(), which does its undo the same way). cfg comes
+ // from the top of run() (read before any purge of kit.json); --purge
+ // removes kit.json below, so persisting cfg here would recreate it. Each
+ // adapter's own undo() already honors ownership/receipts (opencode's
+ // undoOpencode no-ops when it never held mcp:'ak', and marker-gates
+ // artifact removal independent of that), so this call is unconditional per
+ // host β the only kit-side gate is "did anything actually happen", to
+ // avoid a no-op teardown line (and a needless kit.json rewrite) on a host
+ // that was never enabled. builtinHostsWithLifecycle() (not
+ // hostsWithLifecycle()) deliberately excludes admitted external hosts:
+ // this loop body destructures an opencode-shaped result (ret.undo,
+ // ret.artifacts) β see lifecycle-registry.mjs's registerAdmittedLifecycle
+ // comment for why external lifecycle execution isn't wired through here yet.
+ for (const hostId of builtinHostsWithLifecycle()) {
+ const adapter = lifecycleAdapterFor(hostId);
+ if (dry) {
+ info(`[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`);
+ continue;
+ }
+ const retired = await runLifecycle({ adapter, action: 'undo', cfg });
+ const ret = retired.result;
+ ownershipTeardownOk = ownershipTeardownOk && ret.ok;
+ // Persist markers unconditionally, exactly like x/host.mjs's off()/pick():
+ // undo() mutates cfg's ownership markers in memory even when it rewrote
+ // no file (`undo.changed` measures the FILE, not cfg), so gating the save
+ // on `changed` would strand a stale mcp:'ak' receipt forever on the
+ // quiet-success path. Only the human-facing line stays gated on "did
+ // anything observable happen".
+ if (!flags.purge) saveKitConfig(cfg);
+ if (ret.undo.changed || ret.artifacts.changed || !ret.ok) {
+ (ret.ok ? ok : warn)(ret.ok
+ ? `stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`
+ : `${hostId} teardown incomplete β ${ret.undo.detail}`);
}
}
if (flags.purge && fs.existsSync(paths.kitConfigPath())) {
diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs
index 5d7f445..5e48455 100644
--- a/src/commands/x/host.mjs
+++ b/src/commands/x/host.mjs
@@ -16,8 +16,10 @@ import {
} from '../../lib/providers.mjs';
import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, pruneRoutesForHosts, modelNote, ACTIVITIES } from '../../lib/routing.mjs';
import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs';
-import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../../lib/opencode.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';
@@ -194,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
@@ -337,7 +340,7 @@ async function off({ cwd, pkgRoot }) {
const rufloCodexManaged = cfg.integrations?.ownership?.codex?.reverseMcp === 'ak';
// OpenCode teardown reads its ownership receipt before the host/routing reset.
// A failed teardown retains that receipt (including catalogDir) for a retry.
- const retired = await runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'undo', cfg });
+ const retired = await runLifecycle({ adapter: lifecycleAdapterFor('opencode'), action: 'undo', cfg });
const ret = retired.result;
cfg.providers = {
aqeProvider: null,
@@ -629,7 +632,7 @@ async function pick({ flags, cwd, pkgRoot }) {
warn('opencode: enabled but CLI not installed β wiring skipped (re-run `ak sync` after installing opencode-ai)');
} else {
const lifecycle = await runLifecycle({
- adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'apply', cfg, options: { pkgRoot },
+ adapter: lifecycleAdapterFor('opencode'), action: 'apply', cfg, options: { pkgRoot },
});
const stack = lifecycle.result;
// persist the markers on ANY refresh (converged file + stale markers is
@@ -653,7 +656,7 @@ async function pick({ flags, cwd, pkgRoot }) {
// never the user's own opencode config. A teardown that cannot complete
// (e.g. a JSONC config) is reported honestly β markers stay for the retry
// and "disabled" is never claimed over still-active wiring.
- const retired = await runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'undo', cfg });
+ const retired = await runLifecycle({ adapter: lifecycleAdapterFor('opencode'), action: 'undo', cfg });
const ret = retired.result;
saveKitConfig(cfg); // persist markers (nulled on success, retained on failure)
if (ret.ok) ok(`opencode disabled: ${ret.undo.detail}; ${ret.artifacts.detail}`);
diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs
new file mode 100644
index 0000000..a35d812
--- /dev/null
+++ b/src/lib/adapters/admission.mjs
@@ -0,0 +1,190 @@
+// Fail-closed admission gate for externally-sourced host-adapter manifests
+// (Adapter Contract Dossier). admitAdapters walks cfg.hostAdapters and, for
+// each entry, validates the manifest, verifies its declared contract is one
+// this version supports, and checks a canonicalized content hash against an
+// injected consent store β never prompting interactively itself. Per-entry
+// isolation: one bad adapter is refused in place and never affects any other
+// entry or the built-in registries (try/caught per entry, in admitOne AND as
+// a belt-and-suspenders net in admitAdapters).
+import { createHash } from 'node:crypto';
+import { HOST_REGISTRY } from './registries.mjs';
+import { validateAdapterManifest } from './manifest.mjs';
+
+export const SUPPORTED_CONTRACT = 1;
+
+/** Deterministic, key-sorted JSON β same stable-stringify shape used
+ * elsewhere in this codebase (e.g. opencode.mjs's deepEqual) so two manifests
+ * that differ only in key order or incidental whitespace hash identically.
+ * The sort comparator is a plain code-unit compare, NOT localeCompare: with
+ * localeCompare, key order (and therefore the hash) could shift across
+ * locales (e.g. en_US vs sv_SE collation), so a CI runner or container with
+ * a different locale than where consent was recorded could see a bogus
+ * consent-stale refusal for a manifest that never actually changed. */
+export function canonicalizeManifest(value) {
+ return JSON.stringify(value, (_key, val) => (
+ val && typeof val === 'object' && !Array.isArray(val)
+ ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
+ : val
+ ));
+}
+
+export function hashManifest(value) {
+ return createHash('sha256').update(canonicalizeManifest(value)).digest('hex');
+}
+
+const builtinIds = () => new Set(HOST_REGISTRY.map((host) => host.id));
+
+async function admitOne(entry, { readManifest, consent, builtins }) {
+ const name = entry?.name;
+ if (typeof name !== 'string' || !name) {
+ return { name: name ?? '(unknown)', admitted: false, reason: 'invalid-entry', detail: 'cfg.hostAdapters entry requires a name' };
+ }
+ if (typeof entry.source !== 'string' || !entry.source) {
+ return { name, admitted: false, reason: 'invalid-entry', detail: 'cfg.hostAdapters entry requires a source' };
+ }
+
+ let raw;
+ try {
+ raw = await readManifest(entry.source);
+ } catch (error) {
+ return { name, admitted: false, reason: 'manifest-unreadable', detail: error?.message ?? String(error) };
+ }
+
+ // cfg's own pinned contract (if declared) disagreeing with the manifest's
+ // self-declared contract is a distinct failure from "unsupported version" β
+ // it means the operator pinned to a manifest that changed underneath them.
+ if (entry.contract !== undefined && raw?.contract !== undefined && entry.contract !== raw.contract) {
+ return {
+ name, admitted: false, reason: 'contract-mismatch',
+ detail: `cfg declares contract ${entry.contract}, manifest declares ${raw.contract}`,
+ };
+ }
+
+ let manifest;
+ try {
+ manifest = validateAdapterManifest(raw);
+ } catch (error) {
+ return { name, admitted: false, reason: error?.reason ?? 'manifest-invalid', detail: error?.message ?? String(error) };
+ }
+
+ if (manifest.contract !== SUPPORTED_CONTRACT) {
+ return { name, admitted: false, reason: 'contract-version', detail: `unsupported contract ${manifest.contract}` };
+ }
+
+ if (manifest.host.id !== name) {
+ return { name, admitted: false, reason: 'name-mismatch', detail: `cfg entry '${name}' does not match manifest host id '${manifest.host.id}'` };
+ }
+
+ if (builtins.has(manifest.host.id)) {
+ return { name, admitted: false, reason: 'builtin-shadow', detail: `'${manifest.host.id}' is a built-in host id` };
+ }
+
+ const hash = hashManifest(manifest);
+ let recorded;
+ try {
+ recorded = consent.recordedHashFor(name);
+ } catch (error) {
+ return { name, admitted: false, reason: 'consent-error', detail: error?.message ?? String(error) };
+ }
+ if (recorded == null) {
+ return { name, admitted: false, reason: 'consent-required', detail: `no recorded consent for '${name}'` };
+ }
+
+ let trusted;
+ try {
+ trusted = consent.isTrusted(name, hash);
+ } catch (error) {
+ return { name, admitted: false, reason: 'consent-error', detail: error?.message ?? String(error) };
+ }
+ if (!trusted || recorded !== hash) {
+ return { name, admitted: false, reason: 'consent-stale', detail: `manifest hash ${hash} does not match consented ${recorded}` };
+ }
+
+ return { name, admitted: true, entry: manifest.host, manifest };
+}
+
+/**
+ * @param {{ cfg: any, readManifest: (source: string) => Promise,
+ * consent: { recordedHashFor(name: string): string|null, isTrusted(name: string, hash: string): boolean } }} args
+ * @returns {Promise>}
+ */
+export async function admitAdapters({ cfg, readManifest, consent }) {
+ const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : [];
+ const builtins = builtinIds();
+ const results = [];
+ // Sequential, not Promise.all: entries are independent, but sequential
+ // admission keeps refusal isolation trivial to reason about (and cfg's
+ // adapter counts are small β this is not a hot loop).
+ for (const entry of entries) {
+ try {
+ results.push(await admitOne(entry, { readManifest, consent, builtins }));
+ } catch (error) {
+ // Belt-and-suspenders: admitOne already catches every known failure
+ // point, but an unforeseen throw must still isolate to this one entry.
+ results.push({ name: entry?.name ?? '(unknown)', admitted: false, reason: 'admission-error', detail: error?.message ?? String(error) });
+ }
+ }
+ return results;
+}
+
+async function defaultReadManifest(source) {
+ const fs = await import('node:fs/promises');
+ const raw = await fs.readFile(source, 'utf8');
+ return JSON.parse(raw);
+}
+
+/**
+ * CLI bootstrap entry point β the only call site production code needs
+ * (`bootstrapHostAdapters({cfg, env})`, wired from bin/agentic-kit.mjs).
+ * Everything is guarded and non-fatal: with the flag unset or no configured
+ * adapters, this returns immediately with zero side effects (no dynamic
+ * import, no filesystem read, no admission work) β the flag-off no-op is
+ * pinned by a test. `readManifest`/`consent` are optional injection points
+ * for tests; production relies on the defaults (a plain fs+JSON.parse reader,
+ * and a dynamic import of the sibling consent store in ./consent.mjs).
+ * @param {{ cfg?: any, env?: NodeJS.ProcessEnv, readManifest?: (source: string) => Promise,
+ * consent?: { recordedHashFor(name: string): string|null, isTrusted(name: string, hash: string): boolean } }} [args]
+ */
+export async function bootstrapHostAdapters({
+ cfg, env = process.env, readManifest = defaultReadManifest, consent,
+} = {}) {
+ if (env?.AK_EXPERIMENTAL_HOST_ADAPTERS !== '1') return { active: false, admitted: [], warnings: [] };
+ const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : [];
+ if (entries.length === 0) return { active: false, admitted: [], warnings: [] };
+
+ let consentStore = consent;
+ if (!consentStore) {
+ try {
+ // Sibling-owned module (consent.mjs); dynamic so this file loads even
+ // before it lands, and so tests never pay for it unless they choose to.
+ // consent.mjs exports recordedHashFor/isTrusted as plain named
+ // functions (no wrapper object) β the module namespace itself already
+ // satisfies {recordedHashFor(name), isTrusted(name,hash)}; the
+ // consentStore/default fallbacks are just tolerance for a future
+ // reshape, not the shape it ships today.
+ // Cast to `any`: consent.mjs's real, current shape has neither
+ // `consentStore` nor `default` β the fallback chain below is tolerance
+ // for a future reshape (see the comment above), not today's actual
+ // module namespace type, so a literal type there would just be wrong.
+ const mod = /** @type {any} */ (await import('./consent.mjs'));
+ consentStore = mod.consentStore ?? mod.default ?? mod;
+ } catch (error) {
+ const warnings = entries.map((raw) => ({
+ name: raw?.name ?? '(unknown)', reason: 'consent-unavailable', detail: error?.message ?? String(error),
+ }));
+ return { active: true, admitted: [], warnings };
+ }
+ }
+
+ const results = await admitAdapters({ cfg, readManifest, consent: consentStore });
+ const admitted = results.filter((result) => result.admitted);
+ const warnings = results.filter((result) => !result.admitted)
+ .map(({ name, reason, detail }) => ({ name, reason, detail }));
+
+ if (admitted.length) {
+ const { applyAdmitted } = await import('./admitted.mjs');
+ applyAdmitted(admitted);
+ }
+
+ return { active: true, admitted, warnings };
+}
diff --git a/src/lib/adapters/admitted.mjs b/src/lib/adapters/admitted.mjs
new file mode 100644
index 0000000..21838fe
--- /dev/null
+++ b/src/lib/adapters/admitted.mjs
@@ -0,0 +1,64 @@
+// Overlay holding admitted external host entries β set once per process by
+// bootstrapHostAdapters (admission.mjs) after admitAdapters has already
+// refused any entry that would collide with a built-in id. This module does
+// NOT re-check the built-in-shadow invariant; it trusts admission's output
+// for that. It DOES, however, re-run every stored entry through
+// validateHostAdapter (registries.mjs) before accepting it β see
+// conformOne below.
+import { HOST_REGISTRY, validateHostAdapter } from './registries.mjs';
+
+let admittedEntries = Object.freeze([]);
+let applied = false;
+
+/**
+ * Minimal shape gate + deep-freeze for one overlay entry, by re-running it
+ * through validateHostAdapter with no projections/observability maps
+ * injected (so only structural shape β not registry cross-references β is
+ * re-verified). In the real bootstrapHostAdapters path this is always a
+ * no-op pass-through: admitAdapters already produced `entry` by calling
+ * validateHostAdapter once, inside validateAdapterManifest, so re-running it
+ * here just re-derives an equally-valid, equally-frozen clone. It exists so
+ * a caller that bypasses admission entirely β a bug, a future refactor, or a
+ * raw object handed to applyAdmitted directly β gets a loud construction-time
+ * TypeError instead of an unvalidated, possibly-privileged-looking entry
+ * (e.g. `{id, capabilities:{canBePrimary:true}}`) silently joining
+ * effectiveHostRegistry(). applyAdmitted only accepts genuine admission
+ * output; anything else is rejected, never silently coerced.
+ * @param {any} item β a raw host entry, or an admission result `{entry}`
+ */
+function conformOne(item) {
+ return validateHostAdapter(item?.entry ?? item);
+}
+
+/**
+ * Set the admitted overlay. Accepts either raw host entries or admission
+ * results ({name, admitted, entry, manifest}) β whichever admitAdapters
+ * handed back β validates + deep-freezes the host entry from each (throws on
+ * a non-conforming entry), and stores the result. A second call replaces the
+ * overlay outright (useful for tests via resetAdmitted()); production calls
+ * this at most once per process since the env flag gates it.
+ * @param {ReadonlyArray} entries
+ */
+export function applyAdmitted(entries) {
+ admittedEntries = Object.freeze((entries ?? []).map(conformOne));
+ applied = true;
+ return admittedEntries;
+}
+
+/** Test-only reset back to the unapplied, builtins-only state. */
+export function resetAdmitted() {
+ admittedEntries = Object.freeze([]);
+ applied = false;
+}
+
+export function admittedHostIds() {
+ return admittedEntries.map((entry) => entry.id);
+}
+
+/** Built-ins + admitted externals, both frozen. Returns HOST_REGISTRY itself
+ * (same reference) when nothing has been admitted, so callers that never
+ * opt into the experimental flag see byte-identical behavior. */
+export function effectiveHostRegistry() {
+ if (!applied || admittedEntries.length === 0) return HOST_REGISTRY;
+ return Object.freeze([...HOST_REGISTRY, ...admittedEntries]);
+}
diff --git a/src/lib/adapters/consent.mjs b/src/lib/adapters/consent.mjs
new file mode 100644
index 0000000..a26b94b
--- /dev/null
+++ b/src/lib/adapters/consent.mjs
@@ -0,0 +1,91 @@
+// Hash-pinned adapter trust store (Codex-hooks/Hermes-allowlist precedent).
+// A JSON map of adapter name -> { hash, consentedAt } under the kit config
+// dir. Edit-invalidation is inherent: change an adapter's hook command and
+// its hash changes, so `isTrusted` fails closed until consent is re-granted.
+//
+// No interactive prompting lives here β consent is GRANTED elsewhere (a
+// future `ak host adapters trust ` command). `recordConsent` is the
+// programmatic seam that command will call.
+import fs from 'node:fs';
+import path from 'node:path';
+import { configDir } from '../paths.mjs';
+
+export const adapterConsentPath = () => path.join(configDir(), 'adapter-consent.json');
+
+function readStore(file) {
+ let raw;
+ try {
+ raw = fs.readFileSync(file, 'utf8');
+ } catch {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+/** Atomic tmp+rename write at 0600, matching the rest of the kit's
+ * user-owned config writes (live/process-sessions.mjs's WorkspaceSnapshotStore). */
+function writeStore(file, store) {
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
+ let renamed = false;
+ try {
+ fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
+ fs.renameSync(tmp, file);
+ renamed = true;
+ } finally {
+ if (!renamed) {
+ try { fs.rmSync(tmp, { force: true }); } catch { /* cleanup must not mask the write error */ }
+ }
+ }
+ try { fs.chmodSync(file, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
+}
+
+function validEntry(value) {
+ return !!value && typeof value === 'object'
+ && typeof value.hash === 'string' && value.hash.length > 0
+ && typeof value.consentedAt === 'string' && Number.isFinite(Date.parse(value.consentedAt));
+}
+
+/** The hash this adapter was last consented to, or null if never recorded
+ * (or the file is missing/unreadable/corrupt β this never throws). */
+export function recordedHashFor(name, { file = adapterConsentPath() } = {}) {
+ if (typeof name !== 'string' || !name) return null;
+ const entry = readStore(file)[name];
+ return validEntry(entry) ? entry.hash : null;
+}
+
+/** True only on an exact hash match against the recorded consent. Any
+ * mismatch β including "never consented" β is untrusted. */
+export function isTrusted(name, hash, { file = adapterConsentPath() } = {}) {
+ if (typeof hash !== 'string' || !hash) return false;
+ const recorded = recordedHashFor(name, { file });
+ return recorded !== null && recorded === hash;
+}
+
+/** Record consent for `name` at `hash`, overwriting any prior entry. */
+export function recordConsent(name, hash, { file = adapterConsentPath() } = {}) {
+ if (typeof name !== 'string' || !name) throw new TypeError('recordConsent requires an adapter name');
+ if (typeof hash !== 'string' || !hash) throw new TypeError('recordConsent requires a hash');
+ const store = readStore(file);
+ store[name] = { hash, consentedAt: new Date().toISOString() };
+ writeStore(file, store);
+}
+
+/** Remove any recorded consent for `name`. Returns whether an entry existed. */
+export function revokeConsent(name, { file = adapterConsentPath() } = {}) {
+ if (typeof name !== 'string' || !name) return false;
+ const store = readStore(file);
+ // Object.hasOwn, not `name in store`: `in` walks the prototype chain, so
+ // revokeConsent('constructor') (or 'toString', 'hasOwnProperty', ...)
+ // would read true off Object.prototype and report revoking consent for an
+ // adapter that was never actually consented to.
+ if (!Object.hasOwn(store, name)) return false;
+ delete store[name];
+ writeStore(file, store);
+ return true;
+}
diff --git a/src/lib/adapters/hook-runner.mjs b/src/lib/adapters/hook-runner.mjs
new file mode 100644
index 0000000..168d6bd
--- /dev/null
+++ b/src/lib/adapters/hook-runner.mjs
@@ -0,0 +1,201 @@
+// The ONLY way an external adapter's code ever executes: a supervised,
+// short-lived subprocess. Adapters are untrusted code with an explicit exit
+// door, not co-processes we trust to clean up after themselves β no shell,
+// a minimal environment, bounded captured output, and the whole process
+// group dies the instant a timeout fires.
+//
+// Deliberately simpler than execution/subprocess.mjs: one shot, no streaming
+// summary capture, no graceful-then-forced two-step shutdown. A timed-out
+// adapter hook gets no cleanup grace period; it already spent its budget.
+import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process';
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+const OUTPUT_CAP_BYTES = 256 * 1024;
+const TRUNCATION_MARKER = `\nβ¦[truncated: adapter hook output exceeded ${OUTPUT_CAP_BYTES} bytes]`;
+const STDERR_SEPARATOR = '\n--- stderr ---\n';
+// Bounded wait for the process to actually report closed after a kill signal
+// is sent. SIGKILL is not a promise the OS keeps synchronously; this is a
+// safety net so a stuck kernel-blocked process can't hang the caller forever.
+const KILL_GRACE_MS = 2_000;
+const isWindows = process.platform === 'win32';
+const TIMEOUT_SENTINEL = Symbol('adapter-hook-timeout');
+
+/** Min-wins: whichever side asked for the tighter budget governs. A hook
+ * cannot escalate past a caller-imposed ceiling, and a caller cannot force a
+ * hook to run longer than the hook itself declared it needs. Neither side
+ * specifying a timeout falls back to the 30s default. */
+function resolveTimeout(paramTimeoutMs, hookTimeoutMs) {
+ const candidates = [paramTimeoutMs, hookTimeoutMs].filter(
+ (value) => Number.isFinite(value) && value > 0,
+ );
+ return candidates.length ? Math.min(...candidates) : DEFAULT_TIMEOUT_MS;
+}
+
+/** PATH, HOME, plus caller-passed entries only β never the full
+ * process.env. Adapters must not inherit ak's secrets. */
+function minimalEnv(extra) {
+ const env = {};
+ if (typeof process.env.PATH === 'string') env.PATH = process.env.PATH;
+ if (typeof process.env.HOME === 'string') env.HOME = process.env.HOME;
+ if (extra && typeof extra === 'object') {
+ for (const [key, value] of Object.entries(extra)) {
+ if (typeof key === 'string' && key && typeof value === 'string') env[key] = value;
+ }
+ }
+ return env;
+}
+
+/** Accumulate chunks up to a hard byte ceiling, then silently drop the rest,
+ * remembering that a drop happened. Bounds memory during the run itself β
+ * the final combined-stream cap (and truncation marker) is applied
+ * separately once both streams are known, but a stream truncated on its own
+ * must still be reported as truncated even if it lands exactly at the cap. */
+function boundedCollector(capBytes) {
+ let buffer = Buffer.alloc(0);
+ let truncated = false;
+ return {
+ write(chunk) {
+ if (buffer.length >= capBytes) {
+ if (chunk.length > 0) truncated = true;
+ return;
+ }
+ const next = Buffer.concat([buffer, chunk]);
+ if (next.length > capBytes) {
+ truncated = true;
+ buffer = next.subarray(0, capBytes);
+ } else {
+ buffer = next;
+ }
+ },
+ text() { return buffer.toString('utf8'); },
+ wasTruncated() { return truncated; },
+ };
+}
+
+/** stdout first, then stderr folded in after a separator, then the whole
+ * thing capped at OUTPUT_CAP_BYTES with a truncation marker appended if
+ * anything was cut β whether that happened while collecting a single
+ * oversized stream, or only once the two streams were combined. Never
+ * unbounded. */
+function mergeCapture(stdout, stderr) {
+ const combined = stderr.text ? `${stdout.text}${STDERR_SEPARATOR}${stderr.text}` : stdout.text;
+ const combinedBytes = Buffer.byteLength(combined, 'utf8');
+ const overflow = combinedBytes > OUTPUT_CAP_BYTES;
+ if (!stdout.truncated && !stderr.truncated && !overflow) return combined;
+ const kept = overflow
+ ? Buffer.from(combined, 'utf8').subarray(0, OUTPUT_CAP_BYTES).toString('utf8')
+ : combined;
+ return `${kept}${TRUNCATION_MARKER}`;
+}
+
+function describeFailure(hostId, verb, error) {
+ const reason = error?.code ? `${error.code} (${error.message ?? 'no message'})` : (error?.message ?? String(error));
+ return `${hostId}:${verb} adapter hook failed to start: ${reason}`;
+}
+
+function raceTimeout(promise, ms) {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), ms);
+ promise.then((value) => { clearTimeout(timer); resolve(value); });
+ });
+}
+
+/** Best-effort kill of the whole process group/tree, not just the direct
+ * child β a timed-out adapter hook may have spawned descendants of its own.
+ * POSIX: the child was spawned detached so its pid is also its process group
+ * id; signalling `-pid` reaches the whole group. Windows has no portable
+ * signal for arbitrary console trees, so `taskkill /T /F` owns it there. */
+async function killGroup(child) {
+ if (!Number.isInteger(child?.pid)) return;
+ if (isWindows) {
+ await new Promise((resolve) => {
+ nodeExecFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], () => resolve(undefined));
+ });
+ return;
+ }
+ try {
+ process.kill(-child.pid, 'SIGKILL');
+ } catch {
+ try { child.kill('SIGKILL'); } catch { /* process already gone */ }
+ }
+}
+
+/**
+ * Run one adapter hook as a supervised subprocess and report what happened.
+ * Never throws for process failures (ENOENT, timeout, non-zero exit) β those
+ * are reported via `ok:false` and `detail`. Only malformed call arguments
+ * (missing/invalid `hook`, `hostId`, or `verb`) throw synchronously.
+ *
+ * @param {{hook:{command:string[], timeoutMs?:number}, hostId:string,
+ * verb:string, timeoutMs?:number, env?:Record}} options
+ * @returns {Promise<{ok:boolean, stdout:string, exitCode:number|null, detail:string|null}>}
+ */
+export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /** @type {any} */ ({})) {
+ if (!hook || !Array.isArray(hook.command) || hook.command.length === 0
+ || !hook.command.every((part) => typeof part === 'string' && part.length > 0)) {
+ throw new TypeError('runAdapterHook requires hook.command as a non-empty array of non-empty strings');
+ }
+ if (typeof hostId !== 'string' || !hostId) throw new TypeError('runAdapterHook requires a hostId');
+ if (typeof verb !== 'string' || !verb) throw new TypeError('runAdapterHook requires a verb');
+
+ const effectiveTimeoutMs = resolveTimeout(timeoutMs, hook.timeoutMs);
+ const [argv0, ...args] = hook.command;
+ const childEnv = minimalEnv(env);
+
+ let child;
+ try {
+ child = nodeSpawn(argv0, args, {
+ env: childEnv,
+ shell: false,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ detached: !isWindows,
+ });
+ } catch (error) {
+ return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, error) };
+ }
+
+ const stdoutCollector = boundedCollector(OUTPUT_CAP_BYTES);
+ const stderrCollector = boundedCollector(OUTPUT_CAP_BYTES);
+ child.stdout?.on('data', (chunk) => stdoutCollector.write(chunk));
+ child.stderr?.on('data', (chunk) => stderrCollector.write(chunk));
+
+ let settled = false;
+ let spawnError = null;
+ const closeResult = new Promise((resolve) => {
+ child.once('error', (error) => {
+ spawnError = error;
+ if (!settled) { settled = true; resolve({ code: null, signal: null }); }
+ });
+ child.once('close', (code, signal) => {
+ if (!settled) { settled = true; resolve({ code, signal }); }
+ });
+ });
+
+ const raced = await raceTimeout(closeResult, effectiveTimeoutMs);
+ if (raced === TIMEOUT_SENTINEL) {
+ await killGroup(child);
+ await raceTimeout(closeResult, KILL_GRACE_MS); // best-effort; result unused
+ return {
+ ok: false,
+ exitCode: null,
+ stdout: mergeCapture(
+ { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
+ { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
+ ),
+ detail: `${hostId}:${verb} adapter hook timed out after ${effectiveTimeoutMs}ms and was killed`,
+ };
+ }
+
+ if (spawnError) {
+ return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError) };
+ }
+
+ const { code } = raced;
+ const stdout = mergeCapture(
+ { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
+ { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
+ );
+ return code === 0
+ ? { ok: true, stdout, exitCode: 0, detail: null }
+ : { ok: false, stdout, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}` };
+}
diff --git a/src/lib/adapters/index.mjs b/src/lib/adapters/index.mjs
index 0952eaa..8794885 100644
--- a/src/lib/adapters/index.mjs
+++ b/src/lib/adapters/index.mjs
@@ -6,3 +6,6 @@ export * from './migration.mjs';
export * from './lifecycle.mjs';
export * from './config.mjs';
export * from './ownership.mjs';
+export * from './manifest.mjs';
+export * from './admission.mjs';
+export * from './admitted.mjs';
diff --git a/src/lib/adapters/lifecycle-registry.mjs b/src/lib/adapters/lifecycle-registry.mjs
new file mode 100644
index 0000000..59b6188
--- /dev/null
+++ b/src/lib/adapters/lifecycle-registry.mjs
@@ -0,0 +1,179 @@
+// Lifecycle adapter registry β host lifecycle (detect/plan/apply/verify/undo)
+// reached by id lookup, never by a named import of a concrete host module.
+//
+// Lifecycle adapters carry FUNCTIONS (detect/plan/apply/verify/undo), so they
+// cannot live in the structuredClone'd data registry (registries.mjs) β this
+// module is the function-carrying sibling, keyed the same way (host id).
+//
+// Direction: this module imports the concrete OPENCODE_LIFECYCLE_ADAPTER from
+// opencode.mjs and registers it here, rather than opencode.mjs importing this
+// module and self-registering. opencode.mjs already imports from
+// adapters/config.mjs today, but nothing under adapters/ imports opencode.mjs
+// β so this is a new, one-way edge (adapters/* -> opencode.mjs) that never
+// cycles back (opencode.mjs has no reason to import this module: callers
+// reach it through lifecycleAdapterFor/hostsWithLifecycle instead).
+import { validateLifecycleAdapter, LIFECYCLE_OPERATIONS, lifecycleResult } from './lifecycle.mjs';
+import { HOST_REGISTRY } from './registries.mjs';
+import { effectiveHostRegistry } from './admitted.mjs';
+import { OPENCODE_LIFECYCLE_ADAPTER } from '../opencode.mjs';
+
+const LIFECYCLE_ADAPTERS = new Map();
+
+/**
+ * Register a built-in host's lifecycle adapter. Internal β called by this
+ * module itself, once per built-in, at import time. There is no dynamic or
+ * third-party host concept yet, so this is not a general-purpose plugin API.
+ *
+ * Throws when the host id isn't in HOST_REGISTRY or the adapter doesn't
+ * satisfy validateLifecycleAdapter: a wiring bug in a built-in is a load-time
+ * fault, not a runtime one. `hostRegistry` is overridable so this invariant
+ * is unit-testable directly (a synthetic registry) without needing a
+ * fresh-module import trick.
+ * @param {string} hostId
+ * @param {any} adapter β shape enforced by validateLifecycleAdapter, not the type system
+ * @param {{ hostRegistry?: ReadonlyArray<{id: string}> }} [opts]
+ * @returns {any}
+ */
+export function registerBuiltinLifecycle(hostId, adapter, { hostRegistry = HOST_REGISTRY } = {}) {
+ if (!hostRegistry.some((host) => host.id === hostId)) {
+ throw new TypeError(`lifecycle registry: unknown host id '${hostId}' β not present in HOST_REGISTRY`);
+ }
+ validateLifecycleAdapter(adapter);
+ LIFECYCLE_ADAPTERS.set(hostId, adapter);
+ return adapter;
+}
+
+registerBuiltinLifecycle('opencode', OPENCODE_LIFECYCLE_ADAPTER);
+
+/**
+ * @param {string} hostId
+ * @returns {any|null}|null}
+ */
+export function lifecycleAdapterFor(hostId) {
+ return LIFECYCLE_ADAPTERS.get(hostId) ?? null;
+}
+
+/**
+ * Host ids with a registered lifecycle adapter, in effectiveHostRegistry
+ * order (HOST_REGISTRY plus any admitted externals β not Map-insertion
+ * order) so callers get a deterministic, registry-driven iteration order.
+ * With nothing admitted, effectiveHostRegistry() === HOST_REGISTRY (same
+ * reference), so this is unchanged from the built-ins-only behavior.
+ * @returns {string[]}
+ */
+export function hostsWithLifecycle() {
+ return effectiveHostRegistry().filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id);
+}
+
+/**
+ * Host ids with a registered lifecycle adapter, restricted to BUILT-IN hosts
+ * (LIFECYCLE_ADAPTERS keys intersected with HOST_REGISTRY β never an
+ * admitted external, even after applyAdmitted). setup.mjs/sync.mjs/
+ * uninstall.mjs's lifecycle loops destructure an opencode-SHAPED result
+ * (stack.oc/.plugin/.agents/.skill, ret.undo/.artifacts) β those loop bodies
+ * are not generic across hosts yet. Today that's unreachable dead code,
+ * because registerAdmittedLifecycle is never called in production β but it
+ * is ARMED: the moment something wires an admitted external's lifecycle
+ * adapter in (registerAdmittedLifecycle exists for exactly that), a
+ * non-opencode-shaped host would enter hostsWithLifecycle() and crash one of
+ * those command loops on the first opencode-specific destructure. Command
+ * loops use this function instead until external lifecycle EXECUTION and a
+ * shape-agnostic loop body graduate together, in a later wave.
+ * hostsWithLifecycle() above stays for pure registry queries, unaffected.
+ * @returns {string[]}
+ */
+export function builtinHostsWithLifecycle() {
+ return HOST_REGISTRY.filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id);
+}
+
+// ββ admitted (external) lifecycle adapters βββββββββββββββββββββββββββββββββ
+// A derived adapter never runs third-party code in-process: each declared
+// verb is a thin wrapper that shells out through the sibling's hook runner
+// (runAdapterHook({hook, hostId, verb, timeoutMs, env}) -> {ok, stdout,
+// exitCode, detail}) and interprets stdout as the JSON payload for that verb
+// (e.g. a detect hook prints `{"observed": {...}}`) β the declarative-
+// manifest + subprocess-hooks contract the dossier settled on. A verb the
+// manifest never declared gets an honest no-op: it never ran, so nothing
+// changed.
+
+function parseHookPayload(result) {
+ if (!result || typeof result.stdout !== 'string') return null;
+ try {
+ const parsed = JSON.parse(result.stdout);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+function hookFailureResult(verb, result) {
+ const detail = result?.detail || (result?.stdout || '').trim() || `hook exited ${result?.exitCode ?? 'unknown'}`;
+ if (verb === 'detect' || verb === 'verify') return { observed: null, error: detail };
+ if (verb === 'plan') return { changed: false, operations: [], error: detail };
+ return lifecycleResult({ ok: false, changed: false, errors: [detail] });
+}
+
+/**
+ * Build a five-verb lifecycle adapter for an admitted manifest. Declared
+ * verbs (manifest.lifecycle[verb]) run through the hook runner; undeclared
+ * verbs are honest no-ops satisfying validateLifecycleAdapter's function-per-
+ * verb contract without fabricating a result. `runHook` is injectable β
+ * defaults to a dynamic import of ./hook-runner.mjs (the sibling module),
+ * fetched lazily so this factory (and this whole file) loads cleanly even
+ * before that module exists, and so tests never pay for the import unless
+ * they omit the injection on purpose.
+ * @param {any} manifest β validateAdapterManifest's return shape
+ * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}> }} [opts]
+ */
+export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) {
+ const hostId = manifest.host.id;
+ const declared = manifest.lifecycle ?? {};
+ const adapter = { id: hostId };
+ for (const verb of LIFECYCLE_OPERATIONS) {
+ const hookEntry = declared[verb]?.hook;
+ if (!hookEntry) {
+ adapter[verb] = async () => lifecycleResult({ ok: true, changed: false, facts: null });
+ continue;
+ }
+ adapter[verb] = async (context = {}) => {
+ const run = runHook ?? (await import('./hook-runner.mjs')).runAdapterHook;
+ const result = await run({
+ hook: hookEntry, hostId, verb, timeoutMs: hookEntry.timeoutMs, env: context.env,
+ });
+ if (!result?.ok) return hookFailureResult(verb, result);
+ const payload = parseHookPayload(result);
+ if (payload === null) return hookFailureResult(verb, result);
+ return verb === 'apply' || verb === 'undo' ? lifecycleResult(payload) : payload;
+ };
+ }
+ return adapter;
+}
+
+/**
+ * Register an admitted external manifest's derived lifecycle adapter. Unlike
+ * registerBuiltinLifecycle, this checks the host id against
+ * effectiveHostRegistry() (built-ins + admitted) rather than HOST_REGISTRY β
+ * an admitted-but-not-yet-overlaid host id would otherwise never pass the
+ * built-ins-only check.
+ *
+ * Not called anywhere in production yet: registering an external host here
+ * makes it appear in hostsWithLifecycle() (registry-query, all hosts), but
+ * setup.mjs/sync.mjs/uninstall.mjs deliberately read builtinHostsWithLifecycle()
+ * instead, which stays built-ins-only. External lifecycle EXECUTION and a
+ * shape-agnostic command-loop body (today's loops assume the opencode result
+ * shape) graduate together, in a later wave β wiring a real caller for this
+ * function ahead of that loop-body rewrite would crash setup/sync/uninstall
+ * the moment a non-opencode-shaped host is admitted.
+ * @param {any} manifest
+ * @param {{ runHook?: (args: any) => Promise }} [opts]
+ */
+export function registerAdmittedLifecycle(manifest, { runHook } = {}) {
+ const hostId = manifest.host.id;
+ if (!effectiveHostRegistry().some((host) => host.id === hostId)) {
+ throw new TypeError(`lifecycle registry: unknown host id '${hostId}' β not present in effectiveHostRegistry`);
+ }
+ const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook });
+ validateLifecycleAdapter(adapter);
+ LIFECYCLE_ADAPTERS.set(hostId, adapter);
+ return adapter;
+}
diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs
new file mode 100644
index 0000000..801f89f
--- /dev/null
+++ b/src/lib/adapters/manifest.mjs
@@ -0,0 +1,302 @@
+// Adapter-manifest schema + validator (Adapter Contract Dossier: an external
+// host adapter is DATA plus consented subprocess hooks β no third-party code
+// ever runs in-process). validateAdapterManifest is the STRUCTURAL cap layer:
+// an ineligible claim (canBePrimary, an aqeProvider binding, a command
+// statusline, a path-traversal guidanceFile) must be INEXPRESSIBLE by a
+// manifest that parses at all, not merely refused later at runtime. Every
+// rejection carries a named `.reason` (ManifestRejected#reason) so admission.mjs
+// and its tests can distinguish failure modes without parsing message text.
+import {
+ assertEnum, assertId, assertRecord, assertStringArray, immutable,
+} from './schema.mjs';
+import { validateHostAdapter, HOST_REGISTRY, PROJECTION_REGISTRY, OBSERVABILITY_REGISTRY } from './registries.mjs';
+import { LIFECYCLE_OPERATIONS } from './lifecycle.mjs';
+
+const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
+
+export const DRIVING_SURFACES = Object.freeze(['cli-subprocess', 'acp', 'mcp']);
+
+// manifest.trust has a lighter shape than host.trust ({ changes: [...] }, no
+// approvalPolicy) β see the Wave 4 final report for why this is a local,
+// manifest-scoped enum rather than an extension of registries.mjs's
+// TRUST_CHANGE_KINDS: every entry in a manifest's trust.changes is, by
+// construction, a third-party-adapter-sourced grant, so one kind suffices
+// today and registries.mjs stays untouched.
+export const MANIFEST_TRUST_KINDS = Object.freeze(['third-party-adapter']);
+const MANIFEST_TRUST_SCOPES = Object.freeze(['project', 'user']);
+
+export class ManifestRejected extends TypeError {
+ constructor(reason, detail) {
+ super(detail ? `${reason}: ${detail}` : reason);
+ this.name = 'ManifestRejected';
+ this.reason = reason;
+ }
+}
+
+// ββ strict allowlists (Wave 4 security remediation, P0-A) ββββββββββββββββββ
+// Consent hashes the VALIDATED manifest (see hashManifest in admission.mjs),
+// not the operator's raw file. Before this allowlist, an unrecognized
+// TOP-LEVEL key was silently dropped (not rejected) before hashing β so
+// hashManifest(manifest) and the operator's reviewed file could diverge in
+// meaning while still hashing identically, and a recorded consent would
+// cover content the operator never saw. Nested extras under host/
+// host.install/host.legacy fared worse: those sub-validators structuredClone
+// the ENTIRE input object (registries.mjs's validateHostAdapter has no
+// allowlist of its own β built-ins must keep working through it unchanged),
+// so an unrecognized nested key survived verbatim into the admitted host
+// entry and effectiveHostRegistry(). That matters specifically for
+// host.install (a future installHost() runs `npm install -g ${host.pkg}` β
+// providers.mjs) and host.legacy (hosts.mjs/providers.mjs read enableEnv,
+// aqeProvider, guidanceFile, etc. for real env/file wiring). These lists are
+// exactly what those two downstream readers (registries.mjs's
+// validateHostAdapter, src/lib/hosts.mjs, src/lib/providers.mjs) consume β
+// widen them only alongside a new legitimate consumer, never speculatively.
+const MANIFEST_ALLOWED_KEYS = Object.freeze([
+ 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust',
+]);
+// Everything validateHostAdapter itself reads (id, label, install,
+// capabilities, trust, enabledByDefault, configProjection, observability)
+// plus the two fields it structuredClones through untouched but that a real
+// downstream reader consumes: `auth` (src/lib/hosts.mjs's HOST_ADAPTERS
+// spreads host.auth for canDriveSession hosts) and `legacy` (this file's own
+// structural caps below, plus hosts.mjs/providers.mjs).
+const HOST_ALLOWED_KEYS = Object.freeze([
+ 'id', 'label', 'install', 'capabilities', 'auth', 'legacy', 'trust', 'configProjection', 'observability', 'enabledByDefault',
+]);
+// bin + externalInstallPolicy are validated by validateHostAdapter;
+// npmPackage is read by providers.mjs (`HOSTS` -> `host.install.npmPackage`
+// -> `npm install -g `) but never checked there β allowlisted here so
+// it round-trips for a BUILT-IN host, then explicitly forbidden below for an
+// EXTERNAL one (an adapter must never name a package ak would install).
+const HOST_INSTALL_ALLOWED_KEYS = Object.freeze(['bin', 'npmPackage', 'externalInstallPolicy']);
+// The legacy fields real built-in hosts carry and real readers consume:
+// guidanceFile/configFormat/statusline/aqeProvider/envMarkers (hosts.mjs's
+// HOST_ADAPTERS) and enableEnv (providers.mjs's HOSTS/commandHosts).
+const HOST_LEGACY_ALLOWED_KEYS = Object.freeze([
+ 'guidanceFile', 'configFormat', 'statusline', 'aqeProvider', 'envMarkers', 'enableEnv',
+]);
+// The canonical host-capability names, derived from a built-in entry so this
+// can't drift from registries.mjs's private HOST_CAPABILITIES list.
+const HOST_CAPABILITY_KEYS = Object.freeze(Object.keys(HOST_REGISTRY[0].capabilities));
+
+/**
+ * Reject any own key of `value` not in `allowed`, named ManifestRejected
+ * ('unknown-field'). A non-plain-object `value` is left alone β the real
+ * structural validator downstream reports that shape error with its own
+ * (existing) reason, so this never masks e.g. "host must be an object".
+ * @param {any} value
+ * @param {readonly string[]} allowed
+ * @param {string} field β dotted path under 'manifest', e.g. 'host.install'; '' for the root
+ */
+function assertNoUnknownKeys(value, allowed, field) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return;
+ const allowedSet = new Set(allowed);
+ for (const key of Object.keys(value)) {
+ if (!allowedSet.has(key)) {
+ const path = field ? `manifest.${field}` : 'manifest';
+ throw new ManifestRejected('unknown-field', `${path} has unknown key '${key}'`);
+ }
+ }
+}
+
+// Built from the exported REGISTRY arrays (not the private *_MAP objects
+// registries.mjs keeps internal) so this module needs no edit to
+// registries.mjs to reuse the same referential-integrity checks built-ins get.
+const projectionMap = Object.fromEntries(PROJECTION_REGISTRY.map((entry) => [entry.id, entry]));
+const observabilityMap = Object.fromEntries(OBSERVABILITY_REGISTRY.map((entry) => [entry.id, entry]));
+
+function validateDetection(value) {
+ assertRecord(value, 'detection');
+ assertNoUnknownKeys(value, ['bin', 'versionArgs', 'versionPattern'], 'detection');
+ try {
+ assertId(value.bin, 'detection.bin');
+ } catch (error) {
+ throw new ManifestRejected('invalid-detection', error.message);
+ }
+ if (value.versionArgs !== undefined) {
+ try {
+ assertStringArray(value.versionArgs, 'detection.versionArgs');
+ } catch (error) {
+ throw new ManifestRejected('invalid-detection', error.message);
+ }
+ }
+ if (value.versionPattern !== undefined) {
+ if (typeof value.versionPattern !== 'string' || !value.versionPattern) {
+ throw new ManifestRejected('invalid-detection', 'detection.versionPattern must be a non-empty string');
+ }
+ try {
+ new RegExp(value.versionPattern); // validity probe only, never used to match here
+ } catch {
+ throw new ManifestRejected('invalid-detection', 'detection.versionPattern must be a valid regular expression source');
+ }
+ }
+ return structuredClone(value);
+}
+
+function validateDriving(value) {
+ assertRecord(value, 'driving');
+ assertNoUnknownKeys(value, ['surfaces'], 'driving');
+ try {
+ assertStringArray(value.surfaces, 'driving.surfaces', { allowEmpty: false });
+ } catch (error) {
+ throw new ManifestRejected('invalid-driving-surfaces', error.message);
+ }
+ for (const surface of value.surfaces) {
+ if (!DRIVING_SURFACES.includes(surface)) {
+ throw new ManifestRejected('invalid-driving-surfaces', `unknown driving surface: ${surface}`);
+ }
+ }
+ return structuredClone(value);
+}
+
+function validateManifestLifecycle(value) {
+ assertRecord(value, 'lifecycle');
+ for (const [verb, entry] of Object.entries(value)) {
+ if (!LIFECYCLE_OPERATIONS.includes(verb)) {
+ throw new ManifestRejected('invalid-lifecycle-verb', `unknown lifecycle verb: ${verb}`);
+ }
+ assertRecord(entry, `lifecycle.${verb}`);
+ assertNoUnknownKeys(entry, ['hook'], `lifecycle.${verb}`);
+ assertRecord(entry.hook, `lifecycle.${verb}.hook`);
+ assertNoUnknownKeys(entry.hook, ['command', 'timeoutMs'], `lifecycle.${verb}.hook`);
+ try {
+ assertStringArray(entry.hook.command, `lifecycle.${verb}.hook.command`, { allowEmpty: false });
+ } catch (error) {
+ throw new ManifestRejected('invalid-lifecycle-hook', error.message);
+ }
+ if (entry.hook.timeoutMs !== undefined
+ && (!Number.isInteger(entry.hook.timeoutMs) || entry.hook.timeoutMs <= 0)) {
+ throw new ManifestRejected('invalid-lifecycle-hook', `lifecycle.${verb}.hook.timeoutMs must be a positive integer`);
+ }
+ }
+ return structuredClone(value);
+}
+
+function validateManifestTrust(value) {
+ assertRecord(value, 'trust');
+ assertNoUnknownKeys(value, ['changes'], 'trust');
+ if (!Array.isArray(value.changes)) throw new ManifestRejected('invalid-trust', 'trust.changes must be an array');
+ const ids = new Set();
+ for (const [index, change] of value.changes.entries()) {
+ const field = `trust.changes[${index}]`;
+ // Unknown-key rejection stays OUTSIDE the catch so it keeps the uniform
+ // 'unknown-field' reason every other sub-structure uses, rather than being
+ // re-labeled 'invalid-trust'.
+ if (change && typeof change === 'object' && !Array.isArray(change)) {
+ assertNoUnknownKeys(change, ['id', 'kind', 'scope', 'owner', 'value', 'effect'], field);
+ }
+ try {
+ assertRecord(change, field);
+ assertId(change.id, `${field}.id`);
+ } catch (error) {
+ throw new ManifestRejected('invalid-trust', error.message);
+ }
+ if (ids.has(change.id)) throw new ManifestRejected('invalid-trust', `${field} duplicate id: ${change.id}`);
+ ids.add(change.id);
+ try {
+ assertEnum(change.kind, MANIFEST_TRUST_KINDS, `${field}.kind`);
+ assertEnum(change.scope, MANIFEST_TRUST_SCOPES, `${field}.scope`);
+ for (const name of ['owner', 'value', 'effect']) {
+ if (typeof change[name] !== 'string' || !change[name]) throw new TypeError(`${field}.${name} is required`);
+ }
+ } catch (error) {
+ throw new ManifestRejected('invalid-trust', error.message);
+ }
+ }
+ return structuredClone(value);
+}
+
+/**
+ * Validate one adapter manifest document. Throws ManifestRejected (a named
+ * `.reason`) on any structural or capability-cap violation; returns a frozen,
+ * fully independent copy on success. `projections`/`observability` are
+ * injectable for tests, defaulting to the real built-in registries.
+ */
+export function validateAdapterManifest(value, { projections = projectionMap, observability = observabilityMap } = {}) {
+ assertRecord(value, 'manifest');
+ assertNoUnknownKeys(value, MANIFEST_ALLOWED_KEYS, '');
+
+ try {
+ assertId(value.name, 'manifest.name');
+ } catch (error) {
+ throw new ManifestRejected('invalid-id', error.message);
+ }
+
+ if (typeof value.version !== 'string' || !SEMVER_RE.test(value.version)) {
+ throw new ManifestRejected('invalid-version', 'manifest.version must be semver (e.g. 1.2.3)');
+ }
+
+ if (!Number.isInteger(value.contract) || value.contract < 1) {
+ throw new ManifestRejected('invalid-contract', 'manifest.contract must be a positive integer');
+ }
+
+ // ββ host-layer allowlist wrapper, BEFORE validateHostAdapter runs ββββββ
+ // validateHostAdapter is shared with the built-in registry (registries.mjs)
+ // and must keep structuredClone-ing whatever it's handed β so the
+ // allowlisting happens here, at the manifest (external-adapter-only) layer,
+ // never inside that shared validator.
+ if (value.host && typeof value.host === 'object' && !Array.isArray(value.host)) {
+ assertNoUnknownKeys(value.host, HOST_ALLOWED_KEYS, 'host');
+ if (value.host.install && typeof value.host.install === 'object' && !Array.isArray(value.host.install)) {
+ assertNoUnknownKeys(value.host.install, HOST_INSTALL_ALLOWED_KEYS, 'host.install');
+ if (value.host.install.npmPackage != null) {
+ throw new ManifestRejected('external-npm-package', 'external adapters may not declare host.install.npmPackage β detect-never-overwrite only');
+ }
+ try {
+ assertId(value.host.install.bin, 'host.install.bin');
+ } catch (error) {
+ throw new ManifestRejected('invalid-install-bin', error.message);
+ }
+ }
+ assertNoUnknownKeys(value.host.legacy, HOST_LEGACY_ALLOWED_KEYS, 'host.legacy');
+ // Capability keys are allowlisted to the canonical set too, so a
+ // differently-cased or extra key ('CanBePrimary', 'ADMIN') can't ride
+ // along inert β the cap-bypass surface is provably closed, not merely
+ // harmless because consumers happen to read the exact lowercase flag.
+ assertNoUnknownKeys(value.host.capabilities, HOST_CAPABILITY_KEYS, 'host.capabilities');
+ }
+
+ let host;
+ try {
+ host = validateHostAdapter(value.host, { projections, observability });
+ } catch (error) {
+ throw new ManifestRejected('invalid-host', error.message);
+ }
+
+ // ββ structural caps: these claims must be INEXPRESSIBLE, not merely
+ // refused at runtime β an external manifest can never assert them true. ββ
+ if (host.capabilities.canBePrimary === true) {
+ throw new ManifestRejected('cap-can-be-primary', 'external adapters may not claim host.capabilities.canBePrimary');
+ }
+ if (host.capabilities.commandStatusline === true) {
+ throw new ManifestRejected('cap-command-statusline', 'external adapters may not claim host.capabilities.commandStatusline');
+ }
+ if (host.legacy != null && host.legacy.aqeProvider != null) {
+ throw new ManifestRejected('cap-aqe-provider', 'external adapters may not claim host.legacy.aqeProvider');
+ }
+ // Wave-1 review's traversal finding, enforced at schema level: guidanceFile
+ // must be an id-shaped slug, never a path (`../../etc/passwd` fails assertId).
+ if (host.legacy != null && host.legacy.guidanceFile !== undefined) {
+ try {
+ assertId(host.legacy.guidanceFile, 'host.legacy.guidanceFile');
+ } catch (error) {
+ throw new ManifestRejected('invalid-guidance-file', error.message);
+ }
+ }
+
+ const detection = validateDetection(value.detection);
+ const driving = validateDriving(value.driving);
+ const lifecycle = value.lifecycle === undefined ? undefined : validateManifestLifecycle(value.lifecycle);
+ const trust = validateManifestTrust(value.trust);
+
+ return immutable({
+ name: value.name,
+ version: value.version,
+ contract: value.contract,
+ host,
+ detection,
+ driving,
+ ...(lifecycle === undefined ? {} : { lifecycle }),
+ trust,
+ });
+}
diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs
index d9cbcd5..29fb535 100644
--- a/src/lib/adapters/registries.mjs
+++ b/src/lib/adapters/registries.mjs
@@ -225,22 +225,97 @@ const hostEntries = [
},
];
+// F-28: was a tuple-array `.map` that derived capabilities by identity
+// comparison (`modelDiscovery: id === 'ollama'`, `pricing: id === 'ollama' ?
+// 'zero' : β¦`) β a construction that cannot express a second local provider
+// needing pricing 'zero' WITHOUT modelDiscovery (ADR-0028's local-openai).
+// Explicit per-entry records instead, matching the style hostEntries already
+// uses; the five pre-existing rows are pinned deep-equal in
+// tests/kit/adapter-registries.test.mjs so this rewrite cannot silently
+// change what ships.
const providerEntries = [
- ['anthropic', 'Anthropic', 'subscription', { kind: 'host-login' }, ['native'], ['claude'], []],
- ['openai', 'OpenAI', 'subscription', { kind: 'host-login' }, ['native', 'openai-compatible'], ['codex'], []],
- ['google', 'Google Gemini', 'metered', { kind: 'environment', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] }, ['native'], ['ruflo', 'aqe'], []],
- ['openrouter', 'OpenRouter', 'metered', { kind: 'environment', env: ['OPENROUTER_API_KEY'] }, ['openai-compatible'], ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], ['openrouter-metadata']],
- ['ollama', 'Ollama', 'local', { kind: 'none' }, ['native', 'openai-compatible', 'anthropic-compatible'], ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], ['ollama-catalog', 'ollama-runtime']],
-].map(([id, label, billing, credentials, transports, projections, observability]) => ({
- id, label, billing, credentials, transports, projections, observability,
- legacy: { apiProvider: id !== 'openrouter' },
- capabilities: {
- modelDiscovery: id === 'ollama', runtimeDiscovery: id === 'ollama',
- pricing: id === 'ollama' ? 'zero' : (id === 'openrouter' ? 'dated-offline' : 'provider-specific'),
- quota: id === 'anthropic' || id === 'openai',
- cacheAccounting: id === 'ollama' ? 'unknown' : 'provider-dependent',
+ {
+ id: 'anthropic', label: 'Anthropic', billing: 'subscription',
+ credentials: { kind: 'host-login' }, transports: ['native'], projections: ['claude'], observability: [],
+ legacy: { apiProvider: true },
+ capabilities: {
+ modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific',
+ quota: true, cacheAccounting: 'provider-dependent',
+ },
+ },
+ {
+ id: 'openai', label: 'OpenAI', billing: 'subscription',
+ credentials: { kind: 'host-login' }, transports: ['native', 'openai-compatible'], projections: ['codex'], observability: [],
+ legacy: { apiProvider: true },
+ capabilities: {
+ modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific',
+ quota: true, cacheAccounting: 'provider-dependent',
+ },
+ },
+ {
+ id: 'google', label: 'Google Gemini', billing: 'metered',
+ credentials: { kind: 'environment', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
+ transports: ['native'], projections: ['ruflo', 'aqe'], observability: [],
+ legacy: { apiProvider: true },
+ capabilities: {
+ modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific',
+ quota: false, cacheAccounting: 'provider-dependent',
+ },
+ },
+ {
+ id: 'openrouter', label: 'OpenRouter', billing: 'metered',
+ credentials: { kind: 'environment', env: ['OPENROUTER_API_KEY'] },
+ transports: ['openai-compatible'], projections: ['ruflo', 'aqe', 'claude', 'codex', 'opencode'],
+ observability: ['openrouter-metadata'],
+ // Unlike the other four, openrouter fronts many vendors' models behind one
+ // aggregator surface rather than being itself a single named vendor's API
+ // (F-28 dead-field trace: apiProviderIds() β the sole consumer β was
+ // removed in #100; the field is kept as honest metadata for any future
+ // reader, not for a live filter).
+ legacy: { apiProvider: false },
+ capabilities: {
+ modelDiscovery: false, runtimeDiscovery: false, pricing: 'dated-offline',
+ quota: false, cacheAccounting: 'provider-dependent',
+ },
+ },
+ {
+ id: 'ollama', label: 'Ollama', billing: 'local',
+ credentials: { kind: 'none' },
+ transports: ['native', 'openai-compatible', 'anthropic-compatible'],
+ projections: ['ruflo', 'aqe', 'claude', 'codex', 'opencode'],
+ observability: ['ollama-catalog', 'ollama-runtime'],
+ legacy: { apiProvider: true },
+ capabilities: {
+ modelDiscovery: true, runtimeDiscovery: true, pricing: 'zero',
+ quota: false, cacheAccounting: 'unknown',
+ },
},
-}));
+ // ADR-0028: one generic local provider for any OpenAI-compatible model
+ // server on loopback (MLX, LM Studio, llama.cpp, vLLM, user-named
+ // endpoints), instead of enumerating vendors.
+ {
+ id: 'local-openai', label: 'Local OpenAI-compatible', billing: 'local',
+ credentials: { kind: 'none' }, transports: ['openai-compatible'],
+ // No 'aqe' β ollama is an AQE provider type, local-openai deliberately is
+ // not (ADR-0028's stated asymmetry). No 'claude' β assertValidBinding
+ // gates a binding's projection through provider.projections, and
+ // claude's own configProjection ('claude') expects an
+ // anthropic-compatible surface; this row claims only openai-compatible,
+ // so 'claude' is absent by design, not by oversight.
+ projections: ['ruflo', 'codex', 'opencode'],
+ // No daemon API ak has verified for a generic endpoint (unlike ollama's
+ // catalog/runtime sources), so no observability sources.
+ observability: [],
+ // Same reasoning as openrouter above: a generic OpenAI-compatible proxy
+ // for an arbitrary user-run server is not itself a distinct named
+ // vendor's API.
+ legacy: { apiProvider: false },
+ capabilities: {
+ modelDiscovery: false, runtimeDiscovery: false, pricing: 'zero',
+ quota: false, cacheAccounting: 'unknown',
+ },
+ },
+];
const HOST_MAP = registryFrom(hostEntries,
(entry) => validateHostAdapter(entry, { projections: PROJECTION_MAP, observability: OBSERVABILITY_MAP }), 'host');
diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs
index 3b993be..af6fa92 100644
--- a/src/lib/blocks.mjs
+++ b/src/lib/blocks.mjs
@@ -19,6 +19,7 @@ import path from 'node:path';
import { claudeDir, claudeMdPath, codexDir, opencodeDir, home } from './paths.mjs';
import { have } from './exec.mjs';
import { writeFileWithBackup } from './file-write.mjs';
+import { HOST_REGISTRY } from './adapters/index.mjs';
export const BEGIN = (slug) => ``;
export const END = (slug) => ``;
@@ -255,38 +256,104 @@ export function blocksForTarget(rows, targetName) {
* carries a detector that never fires (`{type:'retired'}` β detect() falls to
* its default false), so a present block is stripped and an absent one is a
* no-op β the original detector (e.g. a live `flag`) can never re-upsert it into
- * a file it must stay out of. Absent from the file β nothing happens (no write). */
-export function retiredForTarget(rows, targetName) {
+ * a file it must stay out of. Absent from the file β nothing happens (no write).
+ *
+ * `knownTargets` (optional) is the full universe of REAL target names β e.g.
+ * `guidanceTargets().map(t => t.name)`. When supplied, a row is only force-
+ * stripped when it names at least one target from that universe: it was
+ * legitimately re-scoped AWAY from `targetName` to some other real target
+ * (F-17's original migration case β see the dual-mode-reference tests). A row
+ * whose `guidanceFiles` name NOTHING in `knownTargets` (a typo, or a target no
+ * currently-registered host produces) is left alone instead of being force-
+ * stripped from every real file β this module has no basis for treating an
+ * unrecognized target as "retired from here." Omitting `knownTargets`
+ * preserves the original unconditional behavior for every existing 2-arg call
+ * site (status.mjs, nudge.mjs, opencode.mjs β this refactor's edit boundary
+ * doesn't cover them; only `reconcileGuidance` below opts into the 3-arg
+ * form). For the registry as it ships today every row's `guidanceFiles` are
+ * already within the known-target universe, so this is a no-observable-change
+ * refinement, not a behavior change. */
+export function retiredForTarget(rows, targetName, knownTargets) {
return rows
- .filter((r) => !(r.guidanceFiles ?? ['claude']).includes(targetName))
+ .filter((r) => {
+ const files = r.guidanceFiles ?? ['claude'];
+ if (files.includes(targetName)) return false;
+ if (!knownTargets) return true;
+ return files.some((f) => knownTargets.includes(f));
+ })
.map((r) => ({ ...r, detector: { type: 'retired' } }));
}
-/** The logical guidance targets `sync` (apply) and `status` (dry-run) both loop.
- * ONE source of truth so the two commands can never drift. Always: machine-wide
- * `~/.claude/CLAUDE.md` (claude) + the project's own `/AGENTS.md` (agents).
- * The machine-scoped `~/.codex/AGENTS.md` (agents-user) is included ONLY when
- * `~/.codex` already exists β codex's presence signal β and is NEVER created by
- * this discovery (dir-exists gate, no mkdir). That single gate covers both cases:
- * a codex machine that is momentarily single-host still gets the target (so a
- * stale block can be stripped), and a codex-less machine never grows a ~/.codex.
- * `~/.config/opencode/AGENTS.md` (agents-opencode) follows the identical rule
- * (opencode's config home is its presence signal; opencode prefers this file
- * over ~/.claude/CLAUDE.md, so it needs its own managed copy rather than
- * inheriting claude's). `cfg` is accepted for call-site symmetry/forward-compat;
- * the target set is cfg-independent today. `codexRoot`/`opencodeRoot` are test
- * seams (default to the real dirs).
- * @param {{ cwd?: string, cfg?: object, codexRoot?: string, opencodeRoot?: string }} opts */
-export function guidanceTargets({ cwd = process.cwd(), codexRoot = codexDir(), opencodeRoot = opencodeDir() } = {}) {
- const targets = [
- { name: 'claude', label: 'CLAUDE.md', file: claudeMdPath() },
- { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') },
- ];
- if (fs.existsSync(codexRoot)) {
- targets.push({ name: 'agents-user', label: '~/.codex/AGENTS.md', file: path.join(codexRoot, 'AGENTS.md') });
- }
- if (fs.existsSync(opencodeRoot)) {
- targets.push({ name: 'agents-opencode', label: 'opencode AGENTS.md', file: path.join(opencodeRoot, 'AGENTS.md') });
+/** Per-host guidance-target construction (F-17). Every host in `hosts` that
+ * declares `capabilities.nativeGuidance` contributes a target named after its
+ * `legacy.guidanceFile` β the logical name HOST_REGISTRY already carries but
+ * which nothing consumed before this. Paths/labels/presence-gating stay a
+ * caller concern (see the module header comment: "Logical names only; paths
+ * stay a caller concern"), so this module still owns the bespoke mapping for
+ * the three hosts it knows about:
+ * - claude β 'claude': machine-wide `~/.claude/CLAUDE.md`, always included.
+ * - codex β 'agents': the project's own `/AGENTS.md`, always included
+ * (the AGENTS.md convention is host-neutral, not gated on codex being
+ * installed). codex ALSO contributes a companion machine-scoped target,
+ * 'agents-user' (`~/.codex/AGENTS.md`) β included only when `~/.codex`
+ * already exists (codex's presence signal) and NEVER created by this
+ * discovery (dir-exists gate, no mkdir: a momentarily single-host codex
+ * machine still gets the target so a stale block can be stripped; a
+ * codex-less machine never grows a ~/.codex). This companion has no
+ * `guidanceFile` entry of its own in HOST_REGISTRY β the registry models
+ * one logical guidance file per host today, and adding a second field is
+ * outside blocks.mjs's edit boundary β so it stays keyed off the codex
+ * host id rather than being independently derived.
+ * - opencode β 'agents-opencode': `~/.config/opencode/AGENTS.md`, included
+ * only when opencode's config home already exists (identical presence
+ * rule to agents-user; opencode prefers this file over
+ * ~/.claude/CLAUDE.md, so it needs its own managed copy).
+ * A host id this module has no bespoke mapping for still joins the loop (it is
+ * not silently dropped the way the old hardcoded array would drop it): it gets
+ * a generic, always-on target named after its own `guidanceFile`. This is what
+ * makes the list "derived" rather than closed β see the synthetic-host test in
+ * tests/kit/guidance-targets.test.mjs.
+ * `cfg` is accepted for call-site symmetry/forward-compat; the target set is
+ * cfg-independent today. `codexRoot`/`opencodeRoot`/`hosts` are test seams
+ * (default to the real dirs / the real registry).
+ * @param {{ cwd?: string, cfg?: object, codexRoot?: string, opencodeRoot?: string, hosts?: Array }} opts */
+export function guidanceTargets({
+ cwd = process.cwd(), codexRoot = codexDir(), opencodeRoot = opencodeDir(), hosts = HOST_REGISTRY,
+} = {}) {
+ const targets = [];
+ for (const host of hosts) {
+ if (!host?.capabilities?.nativeGuidance) continue;
+ const name = host.legacy?.guidanceFile;
+ if (!name) continue;
+ switch (host.id) {
+ case 'claude':
+ targets.push({ name, label: 'CLAUDE.md', file: claudeMdPath() });
+ break;
+ case 'codex':
+ targets.push({ name, label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') });
+ if (fs.existsSync(codexRoot)) {
+ targets.push({ name: 'agents-user', label: '~/.codex/AGENTS.md', file: path.join(codexRoot, 'AGENTS.md') });
+ }
+ break;
+ case 'opencode':
+ if (fs.existsSync(opencodeRoot)) {
+ targets.push({ name, label: 'opencode AGENTS.md', file: path.join(opencodeRoot, 'AGENTS.md') });
+ }
+ break;
+ default:
+ // Generic fallback so an unrecognized-but-nativeGuidance host still
+ // joins the reconciliation loop instead of being silently unlooped.
+ // Id-shaped names only: path.join normalizes '..' so a hostile
+ // guidanceFile could escape cwd into a real write primitive
+ // (reconcileGuidance β syncBlocks). A non-conforming name is skipped
+ // entirely β excluded-and-safe beats sanitized-and-surprising.
+ // Schema-level validation of legacy.guidanceFile is the wave-4
+ // admission gate's job, deliberately not duplicated here.
+ if (/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
+ targets.push({ name, label: name, file: path.join(cwd, `${name}.md`) });
+ }
+ break;
+ }
}
return targets;
}
@@ -311,8 +378,10 @@ export async function reconcileGuidance({ cwd, cfg, pkgRoot, context = {}, dryRu
const rows = registry(cfg.customBlocks);
const resolve = templateResolver(pkgRoot);
const out = [];
- for (const t of guidanceTargets({ cwd, cfg })) {
- const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name)];
+ const targets = guidanceTargets({ cwd, cfg });
+ const knownTargets = targets.map((t) => t.name);
+ for (const t of targets) {
+ const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name, knownTargets)];
const res = await syncBlocks(t.file, treg, resolve, { context, dryRun });
const changed = res.filter((r) => r.action !== 'unchanged' && r.action !== 'skipped')
.map((r) => `${r.slug} ${r.action}`).join(', ');
diff --git a/src/lib/config.mjs b/src/lib/config.mjs
index 20bb0b2..1d7424c 100644
--- a/src/lib/config.mjs
+++ b/src/lib/config.mjs
@@ -46,10 +46,39 @@ const DEFAULTS = {
statusline: { codex: null }, // {preset,lastProjection}: explicit ownership of Codex [tui] keys
customBlocks: [], // [{slug, templatePath, detector:{type:'command'|'dir'|'file', target}}]
versionCheck: { ttlHours: 24, last: null, seen: {} },
+ // Experimental adapter door (staged behind AK_EXPERIMENTAL_HOST_ADAPTERS=1):
+ // [{name, source, contract}] entries admitted by admission.mjs's
+ // admitAdapters/bootstrapHostAdapters. Empty by default = zero effect.
+ hostAdapters: [],
};
const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
+// F-14: kit.json top-level keys this ak version understands, derived from
+// DEFAULTS (the envelope already lists every recognized key, versioned
+// sub-objects included) rather than a second literal that could drift.
+const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULTS));
+
+// Warn once per process per distinct unknown-key set, not once per load β
+// loadKitConfig runs on nearly every command invocation.
+const warnedUnknownKeySignatures = new Set();
+
+function warnUnknownTopLevelKeys(parsed) {
+ if (!plain(parsed)) return;
+ const unknown = Object.keys(parsed).filter((key) => !KNOWN_TOP_LEVEL_KEYS.has(key));
+ if (unknown.length === 0) return;
+ const signature = [...unknown].sort().join(',');
+ if (warnedUnknownKeySignatures.has(signature)) return;
+ warnedUnknownKeySignatures.add(signature);
+ // Deliberately console.error, not lib/output.mjs's warn() β that helper
+ // writes to stdout (console.log), which would corrupt `--json` consumers
+ // of loadKitConfig. console.error here mirrors the existing stderr-only
+ // warning convention in commands/run.mjs.
+ console.error(
+ `kit.json keys not recognized by this ak version: ${unknown.join(', ')} β preserved, ignored`,
+ );
+}
+
function assertLoadableEnvelopes(config) {
if (!plain(config.integrations)) {
throw new TypeError(
@@ -145,6 +174,7 @@ export function loadKitConfig(file = kitConfigPath()) {
} catch (error) {
throw new KitConfigError(cand, error.message, { cause: error });
}
+ warnUnknownTopLevelKeys(parsed);
// Migrate raw presence before defaults can masquerade as legacy user intent.
try {
return structuredClone(withDefaults(migrateKitConfig(parsed)));
diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs
index d29f9ba..982f7f4 100644
--- a/src/lib/dashboard-server.mjs
+++ b/src/lib/dashboard-server.mjs
@@ -687,7 +687,13 @@ export function startDashboard({
const usageApi = usage || lazyUsage();
// Injectable like `usage`: tests must never spawn a real codex or read the
// real ~/.config through this route. Lazy for the same reason lazyUsage is.
- const provideLimits = limits || (async () => (await import('./quota.mjs')).readLimits());
+ // enabledHosts drives quota.mjs's F-10 labeling (any OTHER enabled host with
+ // no sanctioned quota channel) from the same kit.json read used elsewhere in
+ // this file (see loadKitConfig() below) β never a second, ad hoc source.
+ const provideLimits = limits || (async () => {
+ const { readLimits } = await import('./quota.mjs');
+ return readLimits({ enabledHosts: loadKitConfig().integrations.hosts });
+ });
const provideLive = typeof live === 'function' ? live : live ? async () => live : lazyLive(liveOptions);
// Same injection contract as `live`: a function is called to produce the
// collector, a value is reused verbatim (tests hand over a fake so the route
diff --git a/src/lib/execution/adapters.mjs b/src/lib/execution/adapters.mjs
index 88f1c59..27e9bb9 100644
--- a/src/lib/execution/adapters.mjs
+++ b/src/lib/execution/adapters.mjs
@@ -11,20 +11,43 @@ export const EXECUTION_ADAPTERS = Object.freeze(new Map([
['opencode', OPENCODE_EXECUTION_ADAPTER],
]));
-// Construction invariant (#88, architecture leg): every routable host needs an
-// execution adapter, and every adapter needs a routable host β enforced at
-// import, exactly like the capability registries' own construction check. A
-// host flipped to canRouteActivities without an adapter (or an adapter added
-// for an unroutable host) would otherwise load cleanly and fail only at
-// runtime with cli_unavailable on every worker β issue #71's trap.
-{
- const routable = new Set(routableHostIds());
- const adapted = new Set(EXECUTION_ADAPTERS.keys());
- const missing = [...routable].filter((id) => !adapted.has(id));
- const stray = [...adapted].filter((id) => !routable.has(id));
- if (missing.length || stray.length) {
+// Construction invariant (#88, amended W1-B per ADR-0019's cli_unavailable
+// degradation precedent): only the built-in -> registry direction throws now.
+// A built-in execution adapter wired for a host the registry no longer marks
+// routable is an in-tree wiring mistake β that still throws loudly at import,
+// exactly like before. The reverse used to throw too (a registry host marked
+// routable with no built-in adapter), but that meant any future host flipped
+// to canRouteActivities:true ahead of its adapter landing would brick this
+// module's import for every consumer of `ak run` β the merge-seam gap this
+// wave exists to open. That direction is no longer a construction error:
+// executionAdapterFor() below returns null for it, and the runner degrades
+// just that one worker with cli_unavailable instead of crashing the run
+// (src/lib/execution/runner.mjs's adapterFor already had this fallback for
+// wholly-unknown hosts β a routable-but-unadapted host now takes the same
+// path, never a new one).
+export function assertBuiltinAdaptersRoutable(routableIds = routableHostIds()) {
+ const routable = routableIds instanceof Set ? routableIds : new Set(routableIds);
+ const stray = [...EXECUTION_ADAPTERS.keys()].filter((id) => !routable.has(id));
+ if (stray.length) {
throw new Error(`execution adapters out of sync with routable hosts: `
- + `${missing.length ? `no adapter for routable host(s): ${missing.join(', ')}. ` : ''}`
- + `${stray.length ? `adapter(s) for non-routable host(s): ${stray.join(', ')}` : ''}`.trim());
+ + `adapter(s) for non-routable host(s): ${stray.join(', ')}`);
}
}
+
+assertBuiltinAdaptersRoutable();
+
+/** Merge seam (W1-B): resolve one host's execution adapter without exposing
+ * the underlying Map. Built-ins resolve here today; a later wave admits
+ * externally-registered adapters into this same lookup. Returns null for a
+ * host with no adapter wired yet β never throws, so callers can degrade a
+ * single worker instead of failing an entire run. */
+export function executionAdapterFor(hostId) {
+ if (EXECUTION_ADAPTERS.has(hostId)) return EXECUTION_ADAPTERS.get(hostId);
+ // Wave 4 (adapter door): an admitted external host whose manifest declares
+ // driving.surfaces including 'cli-subprocess' MAY, in a later wave, get a
+ // constructed execution adapter here. This wave builds none: admitted
+ // hosts have no execution adapter yet, full stop, so they fall through to
+ // the same null return (and the runner's existing cli_unavailable
+ // degradation) as any other routable-but-unadapted host.
+ return null;
+}
diff --git a/src/lib/execution/runner.mjs b/src/lib/execution/runner.mjs
index dad892f..9108b4b 100644
--- a/src/lib/execution/runner.mjs
+++ b/src/lib/execution/runner.mjs
@@ -1,7 +1,7 @@
// Host-neutral execution coordinator. It owns scheduling, deadlines, and
// lifecycle cleanup; adapters own each host's transport and protocol details.
import { validateExecutionAdapter, validateWorkerResult } from './schema.mjs';
-import { EXECUTION_ADAPTERS } from './adapters.mjs';
+import { executionAdapterFor } from './adapters.mjs';
import {
HANDOFF_REQUEST,
normalizeHandoff,
@@ -191,8 +191,18 @@ function validatePlan(plan) {
}
}
+// W1-B: an omitted `adapters` option resolves through the built-in merge
+// seam (executionAdapterFor) instead of a raw Map reference, so a future
+// externally-admitted adapter joins this same lookup without callers here
+// changing. Explicit injection (tests, callers with their own registry)
+// still takes a Map or plain object, unchanged. Only `undefined` selects the
+// built-in seam: an explicit `null` disables ALL adapters (every worker
+// degrades cli_unavailable) rather than meaning "use defaults" β fail-safe,
+// but don't pass null expecting the built-ins.
function adapterFor(adapters, host) {
- const adapter = adapters instanceof Map ? adapters.get(host) : adapters?.[host];
+ const adapter = adapters === undefined
+ ? executionAdapterFor(host)
+ : adapters instanceof Map ? adapters.get(host) : adapters?.[host];
return adapter ? validateExecutionAdapter(adapter) : null;
}
@@ -265,7 +275,7 @@ async function executeWorkerWithEscalation(worker, adapters, {
* A failed dependency blocks descendants; independent branches keep running.
* `escalate: true` enables bounded per-worker ladder retries (ADR-0019). */
export async function executeRunPlan(plan, {
- adapters = EXECUTION_ADAPTERS, cwd = process.cwd(), maxConcurrent = 4, timeoutMs, clock = nowIso, escalate = false,
+ adapters, cwd = process.cwd(), maxConcurrent = 4, timeoutMs, clock = nowIso, escalate = false,
} = /** @type {{adapters?:Record|Map, cwd?:string, maxConcurrent?:number, timeoutMs?:number, clock?:()=>string, escalate?:boolean}} */ ({})) {
validatePlan(plan);
if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) throw new TypeError('maxConcurrent must be a positive integer');
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