diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index 0df99c9..cd1d38c 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -144,6 +144,24 @@ async function main() { allowPositionals: true, strict: false, }); + + // Experimental host-adapter bootstrap (Wave 4, adapter door) β€” the single + // place every command passes through. Gated on the env var BEFORE anything + // else runs so the default (flag unset) is truly zero calls, zero output, + // zero behavior change: no dynamic import, no config read, nothing. + // Refusals are warnings on stderr, never fatal β€” a bad external adapter + // must never block a command that doesn't use it. + if (process.env.AK_EXPERIMENTAL_HOST_ADAPTERS === '1') { + try { + const { loadKitConfig } = await import('../src/lib/config.mjs'); + const { bootstrapHostAdapters } = await import('../src/lib/adapters/admission.mjs'); + const { warnings } = await bootstrapHostAdapters({ cfg: loadKitConfig(), env: process.env }); + for (const w of warnings) { + console.error(dim(`⚠ host adapter '${w.name}' not admitted (${w.reason}): ${w.detail ?? ''}`.trimEnd())); + } + } catch { /* experimental surface β€” never blocks a command */ } + } + const code = await mod.run({ flags: values, positionals, pkgRoot: PKG_ROOT }); // Drift nudge: one line, cached, never blocks (skipped in --json contexts). diff --git a/docs/ADAPTER-CONTRACT-DOSSIER.html b/docs/ADAPTER-CONTRACT-DOSSIER.html new file mode 100644 index 0000000..ca0dd2e --- /dev/null +++ b/docs/ADAPTER-CONTRACT-DOSSIER.html @@ -0,0 +1,698 @@ + + + + + +Adapter Contract Dossier + + + +
+
πŸ“Ž Origin. Authored as a Claude artifact; this file is the repository’s local snapshot for review. Live version: https://claude.ai/code/artifact/d611cd1c-03a4-4c4b-9b9c-94d129b7ee0d
+ +

agentic-kit Β· phase 2 design input Β· grounded 2026-08-14/15

+

Adapter Contract Dossier

+

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

+
+ + + + + + + + + +
SweepGrounded againstHeadline
rufloruvnet/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).
hostsClaude Code docs (2026-08-14) Β· Codex rust-v0.147.0 Β· OpenCode v1.18.18Manifest-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.
hermesNousResearch/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-qeagentic-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

+ + +

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.

+
+ + + + + + + + + + + + + + + + + + + + + + +
AxisInterfaceWhere
RegistriesHOST_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
BindingsassertValidBinding (throwing) Β· validateBinding (error list β€” + wired to ak host status warnings) Β· resolveBinding Β· + validateEndpoint (loopback-http / remote-https / secret-param rules)adapters/bindings.mjs Β· adapters/config.mjs
LifecycleLIFECYCLE_OPERATIONS = [detect, plan, apply, verify, undo] Β· + validateLifecycleAdapter Β· runLifecycle (dry-run gate on apply) Β· + ownership()/mayUndo() teardown-safety receiptsadapters/lifecycle.mjs Β· adapters/ownership.mjs
ExecutionvalidateExecutionAdapter β€” 8 required methods + [readiness, prepare, launch, observe, interpret, summarize, cancel, cleanup] Β· + validateWorkerResult Β· EXIT_CATEGORIES incl. + cli_unavailable graceful degradation Β· + createSubprocessExecutionAdapter Β· + createJsonlSummaryCapture / createPlainTextSummaryCaptureexecution/schema.mjs Β· execution/subprocess.mjs
FactsnormalizedFacts (schemaVersion: 1, provenance-bearing) Β· + provenance()/mergeProvenance() Β· mergeFacts Β· + normalizeIntegrationFactsadapters/facts.mjs
TrustPer-host trust.changes declarations (kinds: auto-approve, mcp-registration, + lifecycle-extension, host-integration; scoped, operation-gated) β€” disclosed before first + mutation; synthetic-host injection already test-proven end-to-endadapters/registries.mjs Β· tests/kit/trust-manifest.test.mjs
ConfigmigrateIntegrationConfig (registry-derived native providers, idempotent, + ambiguity-guarded) Β· versioned integrations/routing envelopesadapters/config.mjs Β· lib/config.mjs
+ +

5.2 The honest verdict: not spaghetti β€” bimodal

+

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.

+ +
+ + + + + + + + + + + + + commands + setup Β· sync Β· host + run Β· status Β· usage + uninstall Β· live Β· about + + + the shell β€” reach-by-name (the debt) + OPENCODE_LIFECYCLE_ADAPTER named import Γ—5 Β· F-02 + frozen 3-host execution map, throws at import Β· F-01 + status: 88-line opencode block Β· F-05  Β·  routing literals Β· F-23 + guidance: 4 literal targets Β· F-17  Β·  uninstall skips undo Β· F-03 + claude-only permission pass Β· F-04  Β·  fixed quota record Β· F-10 + + + the spine β€” reach-by-lookup (the base) + capability queries Β· defaultHostMap() Β· migrator derivation + validators on every load Β· trust disclosure Β· facts merge + truthful attribution (usage Β· live Β· court) β€” moved here + by Phases 0–1 + + + contract core + registries + validators + lifecycle Β· 5 verbs + execution Β· 8 methods + facts Β· schemaVersion 1 + bindings + endpoints + trust manifest + construction-enforced, + host-neutral, tested + + + + + + by name + by lookup + + +
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.
+
+ +
+ + + + + + + + + + + + ak sync + kit.json config + adapters core + opencode adapter + (named import β€” F-02) + host surfaces + + + + + + + + loadKitConfig() + + migrateIntegrationConfig() + + integrations v2 β€” registry-derived defaults + + runLifecycle(detect Β· plan Β· apply Β· verify) β€” reached by name, not lookup + + probe bins Β· write configs (backup-first) + + facts (provenance-graded) + + lifecycleResult Β· ownership receipt + + the five verbs are host-neutral β€” but only one host's adapter is reachable, and only by its name + + +
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

+ +
+ + + + + + + + + + + + + adapter package + manifest.json β€” data only: + registry entry Β· detection + lifecycle data Β· hook argv + declared trust changes + + + admission gate β€” fail closed + same validators as built-ins + caps by schema shape β€” + canBePrimary inexpressible + hash consent Β· re-consent on edit + contract: 1 version match + refused β‡’ named reason, + + + registry + built-ins + admitted + external entries + (experimental flag) + + + every command + setup Β· sync Β· run Β· status + uninstall Β· usage Β· guidance + + + contract core + lifecycle Β· 5 verbs + execution Β· 8 methods + facts Β· provenance + bindings Β· trust + unchanged β€” + already the spine + + + refused β€” built-ins unaffected + + + admit? + + merge + + + one lookup path + + drives + + +
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.
+
+ +
+ + + + + + + + + + + ak sync + admission gate + registry + adapter hook + (subprocess, supervised) + host surface + + + + + + + kit.json hostAdapters[] + + validate Β· caps Β· hash Β· contract β‡’ merge (or refuse, named) + + runLifecycle(host) β€” registry lookup, any admitted host + + spawn hook β€” timeout Β· captured Β· consented + + apply (backup-first) + + claimed result + + independent verify β€” ak reads the surface itself; a host's success claim is never trusted + + uninstall runs the same path with undo β€” the ownership receipt decides what may be removed + + +
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.

+
+ + +

7 Β· Contract sketch

+
+ + + + + + + + + + + + + + + +
ElementShapePrecedent
Registrationkit.json hostAdapters: [{name, source, contract: 1}] β€” explicit, never scannedruflo PluginRegistry.register(); Claude Code --plugin-dir
ManifestOne JSON document: registry entry + detection + lifecycle descriptors + declared trust changes + hook commands; semver'd; content-hashedCodex plugin.json (semver required); aqe QEPluginManifest
AdmissionValidate β†’ cap-check (schema-structural) β†’ hash-verify β†’ contract-version match β†’ per-adapter admit/refuse with named reasonaqe checkPluginSecurity() fail-closed install; ADR-0023
Trustthird-party-adapter trust-manifest kind; hash-pinned hooks, edit re-consent; disclosure before first mutationCodex hook hash-pinning; Hermes consent allowlist; ADR-0021/0023
LifecycleFive verbs (detect/plan/apply/verify/undo) β€” declarative where possible, supervised subprocess hooks where not; uninstall routed through undo (F-03)ak's own validateLifecycleAdapter; OpenCode's low-trust/high-trust tier split
ConformanceFixture adapter in tests/ + black-box harness: real spawns, installed layout, negative assertions, numeric pass/fail; graduation gate for freezing contract: 1ruflo ADR-102 harness + smoke-as-contract (and its #2912 counter-example)
ADR disciplineOne contract ADR per published seam, pinned to an ak version with CI staleness check; graded Working/Demo/TBD table; Accepted only with dated evidenceruflo per-plugin contract ADRs + ADR-015-v2's self-grading table
+ +

8 Β· Impact on the Phase 2 plan

+ + +

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.

+
Settled β€” this dossier +

Q2 Β· Mechanism. Declarative manifest + consented subprocess hooks; no + in-process third-party code; structural capability caps; hash-pinned trust; fail-closed + admission with per-adapter isolation.

+
Open β€” recommendation stands +

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:

+ + +
+ + + diff --git a/docs/HOST-EXTENSIBILITY-EXPLAINER.html b/docs/HOST-EXTENSIBILITY-EXPLAINER.html new file mode 100644 index 0000000..5c4fd3a --- /dev/null +++ b/docs/HOST-EXTENSIBILITY-EXPLAINER.html @@ -0,0 +1,1022 @@ + + + + + +Room for More Hosts + + + +
+
πŸ“Ž Origin. Authored as a Claude artifact; this file is the repository’s local snapshot for review. Live version: https://claude.ai/code/artifact/931c19be-6ce3-45bb-a51d-d8289832cea6
+ +
+

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.

+ +
+ + + + + + + + + BUILT-IN HOSTS β€” here today + + Claude Code + can lead + + Codex + can lead + + OpenCode + routing only + Shipped in the kit β€” trusted because they ARE the kit. + + + + host registry + read by every command + + + + every command + setup Β· status Β· run + sync Β· uninstall + + + EXTERNAL ADAPTERS β€” the door (experimental) + + Hermes + candidate + + Gemini CLI + candidate + + … + Described by a manifest the author ships β€” never code that runs in the kit. + + + + admission gate + validate Β· cap-check + consent Β· fail closed + + + + + compiled in + + + + + if admitted + + + + +
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.
  • +
  • Fully wired: setup, status, running work, teardown.
  • +
  • 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.

+ +
+ + + + + + + + + BEFORE β€” reach each host by name + + new host + + dispatch + status + uninstall + permissions + routing + guidance + usage + …and more + + + + + + + + ~10 edits Β· miss one and the host silently breaks + + + + + AFTER β€” describe it once, read everywhere + + one entry + registry or manifest + + host registry + capability queries + + + dispatch + status + uninstall + permissions + routing + guidance + + + + + + they ask the registry β€” never a name + + +
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.

+ +
+ + + + + + + + + the manifest + one JSON file β€” pure data + + name, version, contract + host { capabilities } + detection { bin } + driving { surfaces } + lifecycle { hooks } + trust { changes } + + Any unknown field is rejected β€” + the schema is a strict allow-list. + + + + admission gate + + βœ“ shape is valid + βœ“ no forbidden claims + βœ“ contract version matches + βœ“ not shadowing a built-in + βœ“ consent matches these bytes + + any miss β†’ refused, by name + + + + + host registry + joins the built-ins + + + + + the hooks β€” run at arm's length + + separate process + no shell Β· env allow-list + + time-boxed + killed if it hangs + + when a step needs logic + + +
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

+
+ + + + + + + + +
QuestionBuilt-in hostExternal 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 kitno β€” subprocess only
Can it install software silently?only disclosed stepsno β€” may not even name a package
What if its manifest is edited after it's trusted?n/atrust is void until re-approved
What happens if it's broken or malicious?n/arefused 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.

+ +
+ + + + + + + + + + ak run + "add feature" + + + + + supervisor (runner) + spawns each worker bounded: + timeout Β· escalation Β· result + + + + architecture β†’ + claude + Β· PRIMARY + supervised worker Β· leads the run + + + implementation β†’ + codex + supervised worker + + + testing β†’ + codex + supervised worker + + + review β†’ + claude + supervised worker + + + + + + + + + + on failure, + escalates to + the primary + + + An enabled OpenCode would appear here as + a supervised worker too β€” never the PRIMARY row. + + Leading = anchors the run (routing + escalation). Supervised = executes one assigned step, bounded. + + +
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.
+
+ +
+ + + + + + + + +
DimensionLeading (primary)Being supervised (routed worker)
Capability flagcanBePrimarycanRouteActivities
Which built-ins have itClaude, CodexClaude, Codex, OpenCode
What it meansAnchors 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 byak host pick --primary-hostthe routing policy, per activity (or a run-local --route)
Degree of controlleads β€” decides the shape, anchors escalationgiven a task, host, model, and deadline; runs; reports back
Bounded?it is the session leadyes β€” timeout, a bounded escalation ladder, a structured result
OpenCodenever (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.

+ +
+ + + + + + + HERE NOW + Admit + register an + external host + from a manifest + + + + Trust + a command to + approve an adapter + (smallest next step) + + + Run + actually drive + work through it + + + Prove + a real adapter + (Hermes) passes + the conformance kit + + + Open + freeze the contract, + drop the flag + + + + + + the experimental switch stays ON until the last step + + +
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

+ + +
+
Limitations β€” read before expecting an external host to "just work"
+ +
+ +

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):

+
{
+  "name": "hermes",
+  "version": "0.1.0",
+  "contract": 1,
+  "host": {
+    "id": "hermes",
+    "label": "Hermes",
+    "capabilities": {
+      "canDriveSession": true,
+      "canRouteActivities": true
+      // canBePrimary / commandStatusline: not allowed β€” omitted by force
+    },
+    "install": { "bin": "hermes", "externalInstallPolicy": "detect-never-overwrite" }
+  },
+  "detection": { "bin": "hermes", "versionArgs": ["--version"] },
+  "driving": { "surfaces": ["cli-subprocess"] },
+  "lifecycle": {
+    "detect": { "hook": { "command": ["hermes", "doctor", "--json"] } }
+  },
+  "trust": { "changes": [ /* what the adapter will touch, disclosed up front */ ] }
+}
+

It is registered in the user's own kit.json, and its exact bytes are approved:

+
// kit.json
+"hostAdapters": [
+  { "name": "hermes", "source": "~/.config/ak/adapters/hermes.json", "contract": 1 }
+]
+ + +

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.

+ +
+ + + + + + + + + + CONTRIBUTOR β€” permissionless, no maintainer needed to start + + 1 Β· Author + manifest + + hook scripts + + + 2 Β· Self-test + run the conformance + kit until green + + + 3 Β· Publish + ship the manifest + (their own repo) + + + 4 Β· Propose + open a PR with the + conformance report + + + + + + + + any user can opt in now + experimental flag + consent + + + + + PR + conformance evidence ↓ + + + MAINTAINER β€” the judge + + 5 Β· Verify + reproduce conformance + + review the hook scripts + + + 6 Β· Decide + which tier? + + + + + Bless β€” stays external + curated list, hash-pinned; + maintainer vouches, users opt in + + + + + Promote β€” into the tree + becomes a registry entry β€” + a normal PR, now easy + + + + + 7 Β· Release β€” built-in + next version ships it, no flag β€” + full parity, like Claude / Codex + + + + + Experimental β–Έβ–Έβ–Έ every step right is more conformance proven and more trust earned β–Έβ–Έβ–Έ full built-in parity + + + + + contributor + + maintainer + + external / experimental outcome + + built-in / shipped outcome + + + +
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

+
    +
  1. 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.
  2. +
  3. 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.
  4. +
  5. 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.
  6. +
  7. 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.
  8. +
  9. 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.
  10. +
  11. Decide the tier. maintainer Based on which + conformance tiers passed, the maintainer chooses where it lands: +
      +
    • Bless it as a curated external adapter stays 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 tree becomes 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.
    • +
    +
  12. +
  13. 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.
  14. +
+ +
+
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.

+ +
+ + + + + + + + + + Contributor's adapter + reaching for a conformance tier + + conformance + + + + agentic-kit β€” the integrator + the host registry Β· the adapter contract Β· execution Β· consistency + Most conformance gaps are fixed right here β€” a normal agentic-kit PR. + + + + ruflo β€” orchestration substrate + drives the loop Β· memory Β· routing Β· swarms + host backends are ENABLE_* targets, + defined inside ruflo, not from outside + ask: register a new host backend + + + + agentic-qe β€” quality substrate + test gen Β· coverage Β· quality court + the provider list is a closed upstream + enum β€” a host can't self-add as one + ask: a provider-plugin API + + + + capability request ↓ + + + ships β†’ lights up ↑ + + + + capability request ↓ + + + ships β†’ lights up ↑ + + +
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 blockerOwnerWhat happens
Can't run work through the host; no trust command; local-file manifests onlyagentic-kitThe 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 typeagentic-qeUpstream β€” 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)rufloUpstream β€” 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 gateagentic-kitMostly 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.

+ +
+ + + diff --git a/docs/HOST-PROVIDER-CONSISTENCY.html b/docs/HOST-PROVIDER-CONSISTENCY.html new file mode 100644 index 0000000..44025a9 --- /dev/null +++ b/docs/HOST-PROVIDER-CONSISTENCY.html @@ -0,0 +1,613 @@ + + + + + +Host & Provider Consistency β€” agentic-kit + + + +
πŸ“Ž Origin. Authored as a Claude artifact; this file is the repository’s local snapshot for review. Live version: https://claude.ai/code/artifact/db1bae65-adf9-45f1-80f1-163dcc1af50c
+ +
+

agentic-kit Β· master consistency document

+

Host & provider axis β€” findings, gaps, options, recommendations

+

+ Prepared 2026-08-13 Β· rev 2 (executive brief added) + Trigger: PR #131 (ADR-0028/0029/0030, adrianco) + Repo: pacphi/agentic-kit @ main (8aad47b) +

+

+ 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

+ + +

The planned work, in phases

+ + +

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. +

+
+ + + + + + + + + + + + + + + + + + + +
TierHostsCan doCannot do
Session hostclaude, codexDrive the ruflo loop (ENABLE_*), be primary, statusline, quota channels, usage scorecard, MCP bridgeβ€”
Supervised execution hostopencodeRoute ak run activities; permission events intercepted (permission_required)Primary, AQE provider, statusline, vendor-diversity credit, usage/quota/live-session attribution
External execution host proposedhermes (ADR-0030)Route activities via an externally maintained adapter; host-declared usage sidecarEverything 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

+
+ + +
IDFindingSeverityScope
F-00Registry 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.healthytoday
+ +

3.2 Dispatch & lifecycle

+
+ + + + + +
IDFindingSeverityScope
F-01EXECUTION_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.blockerext
F-02Lifecycle 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.majorext
F-03ak 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.majortoday
F-04Setup 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.majorext
+ +

3.3 Presentation

+
+ + + + +
IDFindingSeverityScope
F-05status.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.majortoday
F-06About 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.majorext
F-07Statusline 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.minortoday
+ +

3.4 Attribution & observability

+
+ + + + + + + +
IDFindingSeverityScope
F-08Usage 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.majortoday
F-09Live 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.majortoday
F-10Quota 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").minortoday
F-11qeCourt.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.majorext
F-12host.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.minortoday
F-13The 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.majortoday
+ +

3.5 Config & schema

+
+ + + + +
IDFindingSeverityScope
F-14kit.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.majorext
F-15The DEFAULTS host map {claude:true, codex:false, opencode:false} is triplicated (config.mjs:28, providers.mjs:547, x/host.mjs:334).minortoday
F-16validateBinding 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.minortoday
+ +

3.6 Guidance, paths, versions

+
+ + + + + +
IDFindingSeverityScope
F-17Guidance 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.majortoday
F-18versions.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.minortoday
F-19lib/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.minorext
F-20npmRoot(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.)minorext
+ +

3.7 Routing & run

+
+ + + + + +
IDFindingSeverityScope
F-23Routing 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.majortoday
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).minortoday
F-25The 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.minortoday
F-26Managed 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.minortoday
+ +

3.8 Provider axis

+
+ + + + +
IDFindingSeverityScope
F-28providerEntries 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.majortoday
F-29AQE 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.minortoday
F-30Hermes 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.minorext
+ +

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.

+
+ + + + + + + + + + + +
ADRClause at stakeActionTrigger
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
0018Routability→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
0019cli_unavailable graceful degradation (:75-76)Reaffirm; cite as the degradation norm F-01's softening follows.D-3
0010Sanctioned 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
0021Provenance grades for inference evidenceExtend for host-declared usage sidecars (configured grade; Hermes --usage-file is the reference case).D-4 (B)
0020OpenCode's supervised, non-primary shapeAmend to name the host-tier taxonomy (Β§2) as product vocabulary.D-2
0028New β€” local OpenAI-compatible providerAccept after the api_mode: chat_completions citation fix + F-29 note.D-7
0029New β€” extension pointAmend 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
0030New β€” Hermes reference adapterCorrect 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)

+ +

Docs stating a closed set as normative (F-27)

+ + +

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

+ +
+ +
+

Phase 1 β€” Provider axis (D-7)

+

Accept ADR-0028 Β· independent of everything else

+ +
+ +
+

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

+ +
+ +
+

Phase 3 β€” Hermes as first external consumer (ADR-0030) + tier vocabulary (D-2)

+

Adapter externally maintained by its proposer Β· ak carries no hermes-specific code

+ +
+ +
+

Phase 4 β€” Deferred, deliberately

+

Each needs evidence that doesn't exist yet

+ +
+ +

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 groupVerdictNotes
Python CLI, pip/uv, no npmpartialOfficial: 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, profilespartialAll 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-opconfirmedAll five sub-claims; stronger than claimed β€” no hermes mcp subcommand except install can signal failure via exit code (returns swallowed at mcp_config.py:1073 β†’ main.py:12872).
Oneshot -z behaviorconfirmedYOLO/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 aliasespartialollama/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 bridgeconfirmedExactly ten tools (conversations_list, messages_send, permissions_respond, …); zero delegation-shaped tools. The reverse-bridge exclusion is the right security call.
Docs corroborationconfirmedAuthoritative 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. +
+ + +
+ + 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