From 9ca97c71b60f3876ae12af16e1a879f2e97449f6 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 14 Aug 2026 18:46:57 -0400 Subject: [PATCH 1/6] fix: drop the dead statusline consumer, guard no-npm hosts, add a plain-text capture, retire gemini sentinels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 consistency hygiene (F-07, F-20, F-22 + the plain-text capture): statuslineSupported had zero call sites — the registry's commandStatusline capability is the surviving vocabulary (ADR-0015 update note). npmRoot no longer throws for a host without an npm package. createPlainTextSummaryCapture mirrors the JSONL capture's contract for hosts whose oneshot mode emits plain text. Negative tests stop using 'gemini' as the unknown-host sentinel so they cannot silently invert if that id ever registers. --- src/lib/execution/subprocess.mjs | 56 +++++++++++++++++++++++++ src/lib/footprint/install.mjs | 11 ++++- src/lib/hosts.mjs | 7 ---- tests/kit/footprint-collectors.test.mjs | 33 +++++++++++++++ tests/kit/hosts.test.mjs | 14 ++----- tests/kit/routing.test.mjs | 8 ++-- tests/kit/setup-host-flags.test.mjs | 2 +- tests/kit/subprocess-execution.test.mjs | 41 ++++++++++++++++++ 8 files changed, 148 insertions(+), 24 deletions(-) diff --git a/src/lib/execution/subprocess.mjs b/src/lib/execution/subprocess.mjs index ed67438..890f1c8 100644 --- a/src/lib/execution/subprocess.mjs +++ b/src/lib/execution/subprocess.mjs @@ -81,6 +81,62 @@ export function createJsonlSummaryCapture(select, label) { }; } +/** Retain only the final non-empty plain-text line, bounded the same way as + * the JSONL capture above. For hosts whose oneshot mode emits plain text on + * stdout instead of JSONL (Hermes-class hosts): there is no structured event + * to select a field from, so the summary is simply the last non-empty line + * seen — an oversized line is discarded without poisoning a later one. */ +export function createPlainTextSummaryCapture(label) { + if (typeof label !== 'string' || !label) throw new TypeError('plain-text summary capture requires a label'); + let line = ''; + let discarding = false; + let selected = null; + let selectedTooLarge = false; + + const consume = (value) => { + if (!value.trim()) return; + if (Buffer.byteLength(value, 'utf8') > SUMMARY_LINE_LIMIT) { + selectedTooLarge = true; + selected = null; + return; + } + selected = value; + }; + + return { + write(chunk) { + let remaining = String(chunk); + while (remaining) { + const newline = remaining.indexOf('\n'); + const fragment = newline === -1 ? remaining : remaining.slice(0, newline); + remaining = newline === -1 ? '' : remaining.slice(newline + 1); + if (!discarding) { + const next = `${line}${fragment}`; + if (Buffer.byteLength(next, 'utf8') > SUMMARY_LINE_LIMIT) { + line = ''; + discarding = true; + } else { + line = next; + } + } + if (newline !== -1) { + if (!discarding) consume(line.replace(/\r$/, '')); + line = ''; + discarding = false; + } + } + }, + read() { + if (!discarding && line) { + consume(line.replace(/\r$/, '')); + line = ''; + } + if (selectedTooLarge) throw new TypeError(`${label} final output exceeded the ${SUMMARY_LINE_LIMIT}-byte cap`); + return selected; + }, + }; +} + function waitForChild(child, stdout, stderr) { return new Promise((resolve) => { let settled = false; diff --git a/src/lib/footprint/install.mjs b/src/lib/footprint/install.mjs index 56ea3d0..b59735e 100644 --- a/src/lib/footprint/install.mjs +++ b/src/lib/footprint/install.mjs @@ -155,11 +155,20 @@ function safeGlobalRoot() { } } +/** The npm-global root for a package, or null when either the global root is + * unknown or there is no package to look up. `pkg` is absent for a host the + * registry permits to skip npmPackage (host.install.npmPackage is optional + * in the schema); path.join(dir, undefined) throws, so both operands must be + * present before a root is even attempted. */ +export function npmPackageRoot(globalRootDir, pkg) { + return globalRootDir && pkg ? path.join(globalRootDir, pkg) : null; +} + /** The managed-tool descriptors this collector measures, composed from the * registry plus the modules that own the non-npm tools. `kind` says how the * root is found, not how it was installed. */ export function managedTools({ pkgRoot = null, globalRootDir = null } = {}) { - const npmRoot = (pkg) => (globalRootDir ? path.join(globalRootDir, pkg) : null); + const npmRoot = (pkg) => npmPackageRoot(globalRootDir, pkg); const tools = [ { id: 'ruflo', label: 'ruflo', pkg: 'ruflo', bin: 'ruflo', kind: 'npm', root: npmRoot('ruflo') }, { diff --git a/src/lib/hosts.mjs b/src/lib/hosts.mjs index bed8b70..197b217 100644 --- a/src/lib/hosts.mjs +++ b/src/lib/hosts.mjs @@ -34,7 +34,6 @@ export const HOST_ADAPTERS = Object.fromEntries(HOST_REGISTRY guidanceFile: host.legacy.guidanceFile, configFormat: host.legacy.configFormat, statusline: host.legacy.statusline, - statuslineSupported: host.capabilities.commandStatusline, aqeProvider: host.legacy.aqeProvider, envMarkers: host.legacy.envMarkers, auth: host.auth, @@ -48,12 +47,6 @@ export function adapterFor(id) { return HOST_ADAPTERS[id] ?? null; } -/** Whether a host supports a command-backed statusline (claude yes, codex no). - * Callers use this for the "show + explain" UX around claude-only features. */ -export function statuslineSupported(id) { - return !!HOST_ADAPTERS[id]?.statuslineSupported; -} - /** * Which host is driving the current session, as a first-class detected axis. * Precedence: explicit override → confirmed claude markers → any CODEX_* marker diff --git a/tests/kit/footprint-collectors.test.mjs b/tests/kit/footprint-collectors.test.mjs index 7ac7101..bb92112 100644 --- a/tests/kit/footprint-collectors.test.mjs +++ b/tests/kit/footprint-collectors.test.mjs @@ -27,6 +27,7 @@ import { } from '../../src/lib/footprint/projects.mjs'; import { collectCatalog, tomlTableNames } from '../../src/lib/footprint/catalog.mjs'; import { collectConsumers } from '../../src/lib/footprint/consumers.mjs'; +import { npmPackageRoot, managedTools } from '../../src/lib/footprint/install.mjs'; const DAY = 86_400_000; @@ -1122,3 +1123,35 @@ test('allocated size falls back to apparent size where blocks are unusable', (t) // nothing, so the platform argument is what changed the answer. assert.equal(run('darwin').bytes.value, 0); }); + +// ── install: managed-tool npm root resolution ─────────────────────────────── +// The registry schema permits host.install.npmPackage to be absent (only +// `bin` and `externalInstallPolicy` are required — see validateHostAdapter). +// managedTools() cannot inject a synthetic host into HOST_REGISTRY, so this +// exercises the exact guard it relies on: npmPackageRoot(globalRootDir, pkg) +// must return null instead of throwing when either operand is missing. + +test('npmPackageRoot returns null, not a throw, for a host-like entry with no npmPackage', () => { + assert.equal(npmPackageRoot('/opt/npm/global/node_modules', undefined), null); + assert.equal(npmPackageRoot('/opt/npm/global/node_modules', null), null); +}); + +test('npmPackageRoot returns null when the global root itself is unknown', () => { + assert.equal(npmPackageRoot(null, 'ruflo'), null); +}); + +test('npmPackageRoot joins the global root and package when both are known', () => { + assert.equal( + npmPackageRoot('/opt/npm/global/node_modules', 'ruflo'), + path.join('/opt/npm/global/node_modules', 'ruflo'), + ); +}); + +test('managedTools resolves every real registry entry without throwing when a global root is known', () => { + const tools = managedTools({ globalRootDir: '/opt/npm/global/node_modules' }); + assert.ok(tools.length > 0); + // Anti-vacuity: real hosts DO carry npmPackage today, so this also proves + // the guard did not regress the populated-pkg path. + const ruflo = tools.find((tool) => tool.id === 'ruflo'); + assert.equal(ruflo.root, path.join('/opt/npm/global/node_modules', 'ruflo')); +}); diff --git a/tests/kit/hosts.test.mjs b/tests/kit/hosts.test.mjs index f49e2f2..ffd56d8 100644 --- a/tests/kit/hosts.test.mjs +++ b/tests/kit/hosts.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { HOST_IDS, adapterFor, statuslineSupported, drivingHost } from '../../src/lib/hosts.mjs'; +import { HOST_IDS, adapterFor, drivingHost } from '../../src/lib/hosts.mjs'; import { hostAuthState } from '../../src/lib/providers.mjs'; // ── HOST_ADAPTERS descriptors ──────────────────────────────────────────────── @@ -15,7 +15,6 @@ test('claude adapter targets CLAUDE.md/json and supports a statusline', () => { const a = adapterFor('claude'); assert.equal(a.guidanceFile, 'claude'); assert.equal(a.configFormat, 'json'); - assert.equal(a.statuslineSupported, true); assert.equal(a.aqeProvider, 'claude-code'); }); @@ -23,7 +22,6 @@ test('codex adapter targets AGENTS.md/toml and has NO command-backed statusline' const a = adapterFor('codex'); assert.equal(a.guidanceFile, 'agents'); assert.equal(a.configFormat, 'toml'); - assert.equal(a.statuslineSupported, false); assert.deepEqual(a.statusline, { mode: 'builtin', scope: 'user', customCommand: false, multiline: false }); assert.equal(a.aqeProvider, 'codex'); }); @@ -32,18 +30,12 @@ test('opencode adapter targets its own AGENTS.md/json, no statusline, no aqe pro const a = adapterFor('opencode'); assert.equal(a.guidanceFile, 'agents-opencode'); assert.equal(a.configFormat, 'json'); - assert.equal(a.statuslineSupported, false); assert.equal(a.aqeProvider, null); assert.deepEqual(a.envMarkers, []); }); test('adapterFor returns null for an unknown host', () => { - assert.equal(adapterFor('gemini'), null); -}); - -test('statuslineSupported is true for claude and false for codex', () => { - assert.equal(statuslineSupported('claude'), true); - assert.equal(statuslineSupported('codex'), false); + assert.equal(adapterFor('zz-not-a-registered-host'), null); }); // ── drivingHost detection (env-only, deterministic) ────────────────────────── @@ -104,6 +96,6 @@ test('hostAuthState reports none for an absent claude with no key', () => { }); test('hostAuthState returns unknown for an unrecognized host', () => { - const a = hostAuthState('gemini', { env: {}, present: true }); + const a = hostAuthState('zz-not-a-registered-host', { env: {}, present: true }); assert.equal(a.mode, 'unknown'); }); diff --git a/tests/kit/routing.test.mjs b/tests/kit/routing.test.mjs index 89ba320..89bfafb 100644 --- a/tests/kit/routing.test.mjs +++ b/tests/kit/routing.test.mjs @@ -155,7 +155,7 @@ test('routingSummary counts totals, provenance, and per-host tallies', () => { test('validateRoute accepts claude/codex and rejects an unknown host', () => { assert.deepEqual(validateRoute({ host: 'claude', model: 'claude-opus-4-8' }), []); assert.deepEqual(validateRoute({ host: 'codex' }), []); - assert.ok(validateRoute({ host: 'gemini' }).length > 0); + assert.ok(validateRoute({ host: 'zz-not-a-registered-host' }).length > 0); }); test('parseRouteSpecs parses valid specs and warns on bad ones', () => { @@ -163,7 +163,7 @@ test('parseRouteSpecs parses valid specs and warns on bad ones', () => { 'implementation:claude:claude-opus-4-8', 'testing:codex', 'nonsense:claude', // unknown activity - 'review:gemini', // unknown host + 'review:zz-not-a-registered-host', // unknown host ]); assert.deepEqual(policy.implementation, { host: 'claude', model: 'claude-opus-4-8', provenance: 'user' }); assert.deepEqual(policy.testing, { host: 'codex', provenance: 'user' }); @@ -210,10 +210,10 @@ test('an explicit OpenCode route materializes for ak run', () => { }); test('host-neutral run plan rejects a host without activity-routing capability', () => { - const policy = { implementation: { host: 'gemini', provenance: 'user' } }; + const policy = { implementation: { host: 'zz-not-a-registered-host', provenance: 'user' } }; assert.throws( () => materializeRunPlan(policy, { template: 'feature', task: 'x' }), - /route for "implementation" cannot materialize: host "gemini" requires canRouteActivities/, + /route for "implementation" cannot materialize: host "zz-not-a-registered-host" requires canRouteActivities/, ); }); diff --git a/tests/kit/setup-host-flags.test.mjs b/tests/kit/setup-host-flags.test.mjs index 5c08e1d..9a760ff 100644 --- a/tests/kit/setup-host-flags.test.mjs +++ b/tests/kit/setup-host-flags.test.mjs @@ -47,7 +47,7 @@ test('--primary-host claude does not enable codex', () => { test('an unknown --primary-host is ignored with a warning, codex untouched', () => { const cfg = freshCfg(); - const r = applySetupHostFlags(cfg, { 'primary-host': 'gemini' }); + const r = applySetupHostFlags(cfg, { 'primary-host': 'zz-not-a-registered-host' }); assert.equal(cfg.routing.primaryHost, 'claude'); assert.equal(cfg.integrations.hosts.codex, false); assert.equal(r.warnings.length, 1); diff --git a/tests/kit/subprocess-execution.test.mjs b/tests/kit/subprocess-execution.test.mjs index b8f7823..4ba6356 100644 --- a/tests/kit/subprocess-execution.test.mjs +++ b/tests/kit/subprocess-execution.test.mjs @@ -5,6 +5,7 @@ import { createClaudeExecutionAdapter as createRealClaudeExecutionAdapter } from import { createCodexExecutionAdapter as createRealCodexExecutionAdapter } from '../../src/lib/execution/codex.mjs'; import { executeWorker } from '../../src/lib/execution/runner.mjs'; import { HANDOFF_END, HANDOFF_START } from '../../src/lib/execution/handoff.mjs'; +import { createPlainTextSummaryCapture } from '../../src/lib/execution/subprocess.mjs'; const passthroughResolve = (command, args) => ({ command, args, resolved: true }); const createClaudeExecutionAdapter = (options = {}) => createRealClaudeExecutionAdapter({ @@ -289,3 +290,43 @@ test('a subprocess surviving TERM and KILL is reported as orphaned, never timed assert.match(terminal.failure.reason, /did not terminate/); assert.deepEqual(result.signals.slice(0, 2), ['SIGTERM', 'SIGKILL']); }); + +// ── plain-text summary capture (Hermes-class hosts) ────────────────────────── +// De-risks Phase 3: some oneshot hosts emit plain text on stdout rather than +// JSONL. createPlainTextSummaryCapture mirrors createJsonlSummaryCapture's +// write/read contract without the JSON parsing step — the summary is simply +// the last non-empty line seen, bounded the same way. + +test('plain-text capture reads null for empty output', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + assert.equal(capture.read(), null); +}); + +test('plain-text capture selects the final non-empty line across multiple writes', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write('first line\nsecond line\n'); + capture.write('final line'); + assert.equal(capture.read(), 'final line'); +}); + +test('plain-text capture ignores whitespace-only output', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write(' \n\t\n '); + assert.equal(capture.read(), null); +}); + +test('plain-text capture keeps a real line even when trailing whitespace-only lines follow', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write('real summary\n \n'); + assert.equal(capture.read(), 'real summary'); +}); + +test('plain-text capture discards an oversized line without poisoning a later one', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write(`${'x'.repeat(300 * 1024)}\nfinal line\n`); + assert.equal(capture.read(), 'final line'); +}); + +test('plain-text capture requires a label', () => { + assert.throws(() => createPlainTextSummaryCapture(), TypeError); +}); From 8e797903d00cd8f753f1d233f1aead7f15e77946 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 14 Aug 2026 18:47:16 -0400 Subject: [PATCH 2/6] fix: derive host-enablement defaults from the registry and surface binding warnings in host status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 consistency hygiene (F-15, F-16, F-12): host adapters carry a required enabledByDefault boolean and defaultHostMap() replaces the map literal that was triplicated across config, providers, and host-off reset — output is byte-identical for the shipped registry. The previously dead non-throwing validateBinding is wired as bindingWarnings(): ak host status (human output only) names each kit.json binding with an unknown host, unknown provider, or unsupported transport instead of ignoring it silently. The observability axis is documented as terminal validation metadata (ADR-0016 update note). --- src/commands/x/host.mjs | 18 ++++++++- src/lib/adapters/bindings.mjs | 4 ++ src/lib/adapters/registries.mjs | 17 ++++++-- src/lib/config.mjs | 3 +- src/lib/providers.mjs | 4 +- tests/kit/adapter-registries.test.mjs | 33 ++++++++++++++++ tests/kit/host-defaults.test.mjs | 57 +++++++++++++++++++++++++++ 7 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 tests/kit/host-defaults.test.mjs diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index f4454c0..5d7f445 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -18,7 +18,9 @@ import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; -import { routableHostIds } from '../../lib/adapters/index.mjs'; +import { + routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, +} from '../../lib/adapters/index.mjs'; import { newlyEnabledHostTrustManifest, trustManifestLines, } from '../../lib/trust-manifest.mjs'; @@ -149,6 +151,16 @@ export async function run({ flags, positionals, pkgRoot }) { return 2; } +/** Structured, friendly warnings for invalid user-declared bindings in kit.json + * integrations.bindings (F-16: validateBinding had zero call sites — wired + * here so a bad entry surfaces as a warning instead of silent garbage or an + * uncaught TypeError reaching the user). A valid binding list yields []. */ +export function bindingWarnings(cfg) { + return (cfg.integrations?.bindings ?? []).flatMap((binding, index) => + validateBinding(binding, { hosts: HOST_REGISTRY, providers: PROVIDER_REGISTRY }).map((error) => + `kit.json integrations.bindings[${index}].${error.path.replace(/^binding\./, '')}: ${error.code} (${JSON.stringify(error.value)})`)); +} + async function status({ flags, cwd }) { const cfg = loadKitConfig(); const facts = await collectIntegrationFacts({ cwd, cfg }); @@ -170,6 +182,8 @@ async function status({ flags, cwd }) { return 0; } + for (const message of bindingWarnings(cfg)) warn(message); + const dflt = isDefault(cfg); console.log(bold('ruflo agent hosts') + dim(` (wiring scope: ${scope})`)); for (const h of HOSTS) { @@ -331,7 +345,7 @@ async function off({ cwd, pkgRoot }) { models: [], maxBudgetUsd: null, }; - cfg.integrations.hosts = { claude: true, codex: false, opencode: false }; + cfg.integrations.hosts = defaultHostMap(); cfg.routing.primaryHost = DEFAULT_PRIMARY_HOST; cfg.routing.routes = {}; cfg.integrations.ownership ??= {}; diff --git a/src/lib/adapters/bindings.mjs b/src/lib/adapters/bindings.mjs index f4135b2..c4e0e26 100644 --- a/src/lib/adapters/bindings.mjs +++ b/src/lib/adapters/bindings.mjs @@ -69,6 +69,10 @@ export function resolveBinding(bindings, { return candidates.length === 1 ? candidates[0] : null; } +// Non-throwing counterpart to assertValidBinding — wired into `ak host status` +// (src/commands/x/host.mjs bindingWarnings) so an invalid user-declared +// kit.json binding surfaces as a friendly warning list instead of an uncaught +// TypeError (F-16). export function validateBinding(binding, registries, constraints = {}) { const errors = []; const hostIds = new Set(constraints.hosts ?? (registries?.hosts ?? []).map((entry) => entry.id)); diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index cd16551..d9cbcd5 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -93,6 +93,7 @@ export function validateHostAdapter(value, { ['detect-never-overwrite', 'managed', 'unmanaged'], 'host.install.externalInstallPolicy'); validateCapabilities(value.capabilities, HOST_CAPABILITIES, 'host.capabilities'); validateHostTrust(value.trust); + if (typeof value.enabledByDefault !== 'boolean') throw new TypeError('host.enabledByDefault must be boolean'); assertId(value.configProjection, 'host.configProjection'); assertStringArray(value.observability, 'host.observability'); if (projections && !projections[value.configProjection]) { @@ -153,7 +154,7 @@ const OBSERVABILITY_MAP = registryFrom([ const hostEntries = [ { - id: 'claude', label: 'Claude Code', + id: 'claude', label: 'Claude Code', enabledByDefault: true, install: { bin: 'claude', npmPackage: '@anthropic-ai/claude-code', externalInstallPolicy: 'detect-never-overwrite' }, capabilities: { canDriveSession: true, canBePrimary: true, canRouteActivities: true, commandStatusline: true, transcripts: true, usage: true, nativeMcpConfig: true, nativeGuidance: true }, auth: { apiKeyEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'], loginFile: ['.claude', '.credentials.json'], keyOverridesLogin: false }, @@ -178,7 +179,7 @@ const hostEntries = [ configProjection: 'claude', observability: ['claude-transcripts', 'claude-statusline'], }, { - id: 'codex', label: 'OpenAI Codex', + id: 'codex', label: 'OpenAI Codex', enabledByDefault: false, install: { bin: 'codex', npmPackage: '@openai/codex', externalInstallPolicy: 'detect-never-overwrite' }, capabilities: { canDriveSession: true, canBePrimary: true, canRouteActivities: true, commandStatusline: false, transcripts: true, usage: true, nativeMcpConfig: true, nativeGuidance: true }, auth: { apiKeyEnv: ['OPENAI_API_KEY'], loginFile: ['.codex', 'auth.json'], keyOverridesLogin: true }, @@ -199,7 +200,7 @@ const hostEntries = [ configProjection: 'codex', observability: ['codex-transcripts', 'codex-app-server'], }, { - id: 'opencode', label: 'OpenCode', + id: 'opencode', label: 'OpenCode', enabledByDefault: false, install: { bin: 'opencode', npmPackage: 'opencode-ai', externalInstallPolicy: 'detect-never-overwrite' }, capabilities: { canDriveSession: true, canBePrimary: false, canRouteActivities: true, commandStatusline: false, transcripts: true, usage: false, nativeMcpConfig: true, nativeGuidance: true }, auth: { apiKeyEnv: [], loginFile: ['.local', 'share', 'opencode', 'auth.json'], keyOverridesLogin: false }, @@ -247,6 +248,9 @@ const PROVIDER_MAP = registryFrom(providerEntries, (entry) => validateProviderAdapter(entry, { projections: PROJECTION_MAP, observability: OBSERVABILITY_MAP }), 'provider'); export const PROJECTION_REGISTRY = immutable(Object.values(PROJECTION_MAP)); +// Validation metadata + referential integrity only (host.observability entries +// are cross-checked against this set) — deliberately NOT a dispatch surface; +// no collector loop maps an observability id to a live collector (F-12). export const OBSERVABILITY_REGISTRY = immutable(Object.values(OBSERVABILITY_MAP)); export const HOST_REGISTRY = immutable(Object.values(HOST_MAP)); export const PROVIDER_REGISTRY = immutable(Object.values(PROVIDER_MAP)); @@ -274,6 +278,13 @@ export const providerIds = (predicate = () => true) => PROVIDER_REGISTRY.filter( export const primaryHostIds = () => hostIds((host) => host.capabilities.canBePrimary); export const routableHostIds = () => hostIds((host) => host.capabilities.canRouteActivities); export const managedHostIds = () => hostIds((host) => host.capabilities.canDriveSession); +/** Fresh {hostId: enabledByDefault} for every session-driving host — the single + * source for the `{claude:true, codex:false, opencode:false}` literal that used + * to be triplicated across config.mjs, providers.mjs and x/host.mjs (F-15). + * Returns a new object per call since every call site mutates its copy. */ +export const defaultHostMap = () => Object.fromEntries( + HOST_REGISTRY.filter((host) => host.capabilities.canDriveSession).map((host) => [host.id, host.enabledByDefault]), +); export function hostsWithCapability(entriesOrCapability, maybeCapability) { const entries = Array.isArray(entriesOrCapability) ? entriesOrCapability : HOST_REGISTRY; const capability = Array.isArray(entriesOrCapability) ? maybeCapability : entriesOrCapability; diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 718026f..20bb0b2 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -8,6 +8,7 @@ import { CURRENT_INTEGRATIONS_VERSION, migrateIntegrationConfig, } from './adapters/config.mjs'; +import { defaultHostMap } from './adapters/registries.mjs'; import { DEFAULT_PRIMARY_HOST, ROUTING_SCHEMA_VERSION, @@ -25,7 +26,7 @@ const DEFAULTS = { mcp: { register: true, excludeFamilies: [] }, integrations: { version: CURRENT_INTEGRATIONS_VERSION, - hosts: { claude: true, codex: false, opencode: false }, + hosts: defaultHostMap(), bindings: [], }, routing: { diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index de01136..25cc121 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -32,7 +32,7 @@ import { bold, dim, cyan } from './output.mjs'; import { configuredPolicyToAgentOverrides, seedActivityRoutes, resolveRoutes, routingSummary, divergedRoutes, migrateRetiredRoutes, ACTIVITIES, AGENT_ACTIVITY_MAP, PRIMARY_HOSTS } from './routing.mjs'; import { HOST_ADAPTERS } from './hosts.mjs'; import { - HOST_REGISTRY, PROVIDER_REGISTRY, normalizeIntegrationFacts, + HOST_REGISTRY, PROVIDER_REGISTRY, normalizeIntegrationFacts, defaultHostMap, } from './adapters/index.mjs'; import { CURRENT_INTEGRATIONS_VERSION } from './adapters/config.mjs'; import { opencodeMcpStatus } from './opencode.mjs'; @@ -544,7 +544,7 @@ export function applySetupHostFlags(cfg, flags = {}) { version: CURRENT_INTEGRATIONS_VERSION, bindings: [], }); - integrations.hosts ?? (integrations.hosts = { claude: true, codex: false, opencode: false }); + integrations.hosts ?? (integrations.hosts = defaultHostMap()); const routing = cfg.routing ?? (cfg.routing = { version: ROUTING_SCHEMA_VERSION, primaryHost: DEFAULT_PRIMARY_HOST, diff --git a/tests/kit/adapter-registries.test.mjs b/tests/kit/adapter-registries.test.mjs index 11a65a9..9009b96 100644 --- a/tests/kit/adapter-registries.test.mjs +++ b/tests/kit/adapter-registries.test.mjs @@ -6,6 +6,8 @@ import { PROJECTION_REGISTRY, OBSERVABILITY_REGISTRY, validateRegistries, + validateHostAdapter, + defaultHostMap, } from '../../src/lib/adapters/index.mjs'; import { validHost, validProvider, validRegistries, @@ -118,3 +120,34 @@ test('credential descriptors contain environment variable names, never credentia { path: 'providers[0].credentials.names[0]', code: 'invalid-env-name', value: 'sk-secret-value' }, ]); }); + +// F-15: the {claude:true, codex:false, opencode:false} enablement literal used +// to be triplicated across config.mjs, providers.mjs and x/host.mjs. It is now +// derived from HOST_REGISTRY.enabledByDefault via defaultHostMap(). +test('defaultHostMap mirrors the historical host-enablement default', () => { + assert.deepEqual(defaultHostMap(), { claude: true, codex: false, opencode: false }); +}); + +test('defaultHostMap returns a fresh, independently mutable object every call', () => { + const first = defaultHostMap(); + first.codex = true; + assert.notEqual(first, defaultHostMap()); + assert.deepEqual(defaultHostMap(), { claude: true, codex: false, opencode: false }); +}); + +test('every built-in host declares a boolean enabledByDefault', () => { + for (const host of HOST_REGISTRY) { + assert.equal(typeof host.enabledByDefault, 'boolean', `${host.id}.enabledByDefault must be boolean`); + } +}); + +test('validateHostAdapter rejects a host with a missing or non-boolean enabledByDefault', () => { + assert.throws(() => validateHostAdapter(validHost()), /host\.enabledByDefault must be boolean/); + assert.throws(() => validateHostAdapter(validHost({ enabledByDefault: 'yes' })), + /host\.enabledByDefault must be boolean/); +}); + +test('validateHostAdapter accepts a host with an explicit boolean enabledByDefault', () => { + assert.doesNotThrow(() => validateHostAdapter(validHost({ enabledByDefault: true }))); + assert.doesNotThrow(() => validateHostAdapter(validHost({ enabledByDefault: false }))); +}); diff --git a/tests/kit/host-defaults.test.mjs b/tests/kit/host-defaults.test.mjs new file mode 100644 index 0000000..6523be0 --- /dev/null +++ b/tests/kit/host-defaults.test.mjs @@ -0,0 +1,57 @@ +// F-15 (single-source host-enablement default) + F-16 (validateBinding wiring) +// regression coverage for `ak host` — see src/commands/x/host.mjs. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { bindingWarnings } from '../../src/commands/x/host.mjs'; + +const validBinding = { + id: 'ollama-via-claude', host: 'claude', provider: 'ollama', + transport: 'anthropic-compatible', endpoint: 'http://127.0.0.1:11434', +}; + +test('bindingWarnings is empty for an empty or absent binding list', () => { + assert.deepEqual(bindingWarnings({ integrations: {} }), []); + assert.deepEqual(bindingWarnings({ integrations: { bindings: [] } }), []); +}); + +test('bindingWarnings leaves a valid user-declared binding untouched', () => { + assert.deepEqual(bindingWarnings({ integrations: { bindings: [validBinding] } }), []); +}); + +test('bindingWarnings reports an unknown host as a friendly, structured message', () => { + const bindings = [{ id: 'bad', host: 'not-a-host', provider: 'ollama', transport: 'native' }]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^kit\.json integrations\.bindings\[0\]\.host: unknown-host \("not-a-host"\)$/); +}); + +test('bindingWarnings reports an unknown provider as a friendly, structured message', () => { + // An unknown provider also fails the transport check (no known transports + // for it to support) — validateBinding cascades both errors, by design. + const bindings = [{ id: 'bad', host: 'claude', provider: 'not-a-provider', transport: 'native' }]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 2); + assert.ok(warnings.some((w) => /^kit\.json integrations\.bindings\[0\]\.provider: unknown-provider \("not-a-provider"\)$/.test(w))); + assert.ok(warnings.some((w) => /^kit\.json integrations\.bindings\[0\]\.transport: unsupported-transport \("native"\)$/.test(w))); +}); + +test('bindingWarnings reports an unsupported transport as a friendly, structured message', () => { + const bindings = [{ id: 'bad', host: 'claude', provider: 'ollama', transport: 'carrier-pigeon' }]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^kit\.json integrations\.bindings\[0\]\.transport: unsupported-transport \("carrier-pigeon"\)$/); +}); + +test('bindingWarnings indexes each entry independently and leaves valid entries silent', () => { + const bindings = [ + validBinding, + { id: 'bad', host: 'not-a-host', provider: 'ollama', transport: 'native' }, + ]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^kit\.json integrations\.bindings\[1\]\.host: unknown-host/); +}); + +test('bindingWarnings never throws — it always returns a plain array', () => { + assert.doesNotThrow(() => bindingWarnings({ integrations: { bindings: [{}] } })); +}); From 938bc5b6484f94697437e870a8498ccb66175651 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 14 Aug 2026 18:47:36 -0400 Subject: [PATCH 3/6] fix: the usage index never mis-files unknown-host records as claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 consistency hygiene (F-08): parseFile dispatches explicitly on the three known providers and declines anything else instead of falling through to parseClaude; locate()'s cache-hit path returns only claude/codex hits instead of coercing every non-codex provider to claude; scanKey folds the scan roots in as sorted entries so distinct root sets can never share a single-flight promise. Records keep their real provider string — unknown hosts are excluded and labeled, never mis-filed. --- src/lib/usage-index.mjs | 23 ++++++++++---- tests/kit/usage-index.test.mjs | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/lib/usage-index.mjs b/src/lib/usage-index.mjs index 8050331..f43c026 100644 --- a/src/lib/usage-index.mjs +++ b/src/lib/usage-index.mjs @@ -828,6 +828,10 @@ function codexIdFromName(name) { return stem.replace(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-/, ''); } +// Truthfulness policy: dispatch explicitly on the three known providers. A +// codex/claude 2-way ternary silently mis-files anything unrecognized as +// claude; an entry this module doesn't know how to parse must be declined +// (same contract as a parse failure), never mis-labeled. function parseFile(entry) { if (entry.provider === 'opencode') { try { @@ -838,6 +842,7 @@ function parseFile(entry) { return parsed; } catch { return null; } // a parser bug must not cost the user their whole index } + if (entry.provider !== 'codex' && entry.provider !== 'claude') return null; let raw; try { raw = fs.readFileSync(entry.file, 'utf8'); } catch { return null; } try { @@ -1163,10 +1168,13 @@ function aggregate(records, { days, now, cutoff, deps }) { const _inflight = new Map(); let _memo = null; -/** Identity of a scan: two calls sharing it must produce the same aggregate. */ +/** Identity of a scan: two calls sharing it must produce the same aggregate. + * Roots are folded in as sorted [key, value] pairs rather than a hardcoded + * claude/codex/opencode triple, so a root this module doesn't (yet) special- + * case still changes the key instead of colliding with every other scan. */ function scanKey(o = {}) { - const r = o.roots || {}; - return JSON.stringify([Number(o.days) || 14, !!o.force, r.claude || '', r.codex || '', r.opencode || '', o.cachePath || '']); + const roots = Object.entries(o.roots || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return JSON.stringify([Number(o.days) || 14, !!o.force, roots, o.cachePath || '']); } /** Drop process-level state (single-flight promises, read memo, lazy deps). */ @@ -1403,8 +1411,13 @@ function locate(id, r, cacheFile) { const hitFile = idIndexFor(cache).get(id); if (hitFile) { const e = cache.entries[hitFile]; - if (e?.session?.id === id && statSafe(hitFile)) { - const provider = e.session.provider === 'codex' ? 'codex' : 'claude'; + // locate() only ever resolves a claude or codex FILE — opencode sessions + // are resolved upstream via the SQLite store before locate is called. A + // cache entry whose provider is neither is not this function's to answer; + // falling through to the scan loops below reports "not found here" + // instead of silently rewriting the provider to claude. + const provider = e?.session?.provider; + if ((provider === 'claude' || provider === 'codex') && e.session.id === id && statSafe(hitFile)) { return { file: hitFile, provider, id, dirName: provider === 'claude' ? path.basename(path.dirname(hitFile)) : null }; } } diff --git a/tests/kit/usage-index.test.mjs b/tests/kit/usage-index.test.mjs index ab47823..355a3b1 100644 --- a/tests/kit/usage-index.test.mjs +++ b/tests/kit/usage-index.test.mjs @@ -1088,6 +1088,61 @@ test('readCache/locate memo is invalidated when the cache file changes on disk ( 'must still resolve via filename fallback after the cache entry was externally removed — proves the memo picked up the mtime change'); }); +// F-08 (major): the scorecard used to collapse any provider that was neither +// 'codex' nor 'claude' into 'claude' (locate()'s 2-way ternary), and parseFile +// had the same 2-way shape for the JSONL path. Policy: explicit per-id, or +// excluded-and-labeled — NEVER mis-filed as claude. +test('locate() excludes a cached entry with an unrecognized provider instead of mis-filing it as claude (F-08)', async () => { + _resetForTest(); + const sb = sandbox(); + await buildIndex(opts(sb)); + + const raw = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + const [claudeFile, claudeEntry] = Object.entries(raw.entries).find(([, e]) => e.session.provider === 'claude'); + // Doctor the on-disk cache the way an external write (or a forward- + // incompatible future provider) would: same real file, unrecognized + // provider string, a fresh id so the id→file index resolves through it. + const fakeId = 'ffff9999'; + raw.entries[claudeFile] = { ...claudeEntry, session: { ...claudeEntry.session, id: fakeId, provider: 'someother' } }; + fs.writeFileSync(sb.cachePath, JSON.stringify(raw)); + + const result = await readSession(fakeId, opts(sb)); + assert.equal(result, null, + 'an unrecognized provider must never be silently parsed via parseClaude / reported as claude — it must be excluded'); +}); + +test('locate() still resolves known claude/codex cache hits after the truthfulness fix (F-08 regression guard)', async () => { + _resetForTest(); + const sb = sandbox(); + await buildIndex(opts(sb)); // populates the id→file cache both reads below hit + + const claudeHit = await readSession('aaaa1111', opts(sb)); + assert.equal(claudeHit.meta.host, 'claude'); + assert.equal(claudeHit.meta.transcriptProvider, 'claude'); + + const codexHit = await readSession('dddd4444', opts(sb)); + assert.equal(codexHit.meta.host, 'codex'); + assert.equal(codexHit.meta.transcriptProvider, 'codex'); +}); + +// F-08's third bug: scanKey hardcoded the roots portion to a claude/codex/ +// opencode triple, so two scans differing only by a 4th root key (unused by +// scan() today, but a future root) hashed identically and could join the same +// in-flight promise — serving one scan's aggregate to a caller who asked a +// different question. buildIndex's single-flight coalescing is the direct +// consumer of scanKey, so it is the most direct place to observe the fix: +// concurrent calls that differ only by an extra root key must NOT collapse +// into the same in-flight promise (and therefore not the same result object). +test('scanKey distinguishes root sets that differ only by a root key the module does not otherwise use (F-08)', async () => { + _resetForTest(); + const sb = sandbox(); + const a = buildIndex(opts(sb, { roots: { ...sb.roots } })); + const b = buildIndex(opts(sb, { roots: { ...sb.roots, somethingElse: '/nonexistent/for-test' } })); + const [aggA, aggB] = await Promise.all([a, b]); + assert.notEqual(aggA, aggB, + 'a distinct 4th root must not collide with a scan lacking it — they must not share the in-flight promise / result object'); +}); + test('scan prunes cached entries past the 366-day keep window, keeps recent/undated ones', async () => { _resetForTest(); const sb = sandbox(); From 4eaee67947e78f0175d11927a05824f15dc091c6 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 14 Aug 2026 18:50:31 -0400 Subject: [PATCH 4/6] fix: per-id vendor tags stay fail-closed and the migrator infers only registry-known native providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 consistency hygiene (F-11, F-13): vendorOf tags an unrecognized provider id as unregistered: instead of collapsing every unknown into one shared vendor. validatePanel applies the can't-prove-independence rule symmetrically — unregistered vendors never satisfy minVendors, and an unregistered writer with an unregistered jury is treated as could-be-colluding — so ak is never looser than the old shared-'unknown' model (verified against ~280k synthetic panels). The integrations migrator derives each host's native default provider from the provider registry's host-login entries, guarded against ambiguity, and infers no binding at all for hosts without one — the unknown-via- stamping is gone. --- src/lib/adapters/config.mjs | 57 ++++++++++++++++---- src/lib/qeCourt.mjs | 40 ++++++++++++-- tests/kit/integration-config.test.mjs | 57 ++++++++++++++++++-- tests/kit/qeCourt.test.mjs | 75 ++++++++++++++++++++++++++- 4 files changed, 209 insertions(+), 20 deletions(-) diff --git a/src/lib/adapters/config.mjs b/src/lib/adapters/config.mjs index d168484..8d975d8 100644 --- a/src/lib/adapters/config.mjs +++ b/src/lib/adapters/config.mjs @@ -1,8 +1,35 @@ import { isDeepStrictEqual } from 'node:util'; import { immutable } from './schema.mjs'; +import { PROVIDER_REGISTRY } from './registries.mjs'; export const CURRENT_INTEGRATIONS_VERSION = 2; +// A host's native provider = the registry provider with host-login +// credentials whose projections include that host (anthropic -> claude, +// openai -> codex). Derived from the registry instead of a literal map so +// it can't drift from the adapters it describes, and so a host with no such +// provider (e.g. opencode) simply has none — never a synthetic stand-in +// (F-13). Two host-login providers claiming the same host would silently +// last-write-wins in a plain Map build, defeating the whole point of +// deriving this from the registry — fail loudly at load time instead. +function buildNativeProviderByHost(providers) { + const byHost = new Map(); + for (const provider of providers) { + if (provider.credentials?.kind !== 'host-login') continue; + for (const host of provider.projections) { + const existing = byHost.get(host); + if (existing && existing !== provider.id) { + throw new Error( + `adapter registry ambiguity: both '${existing}' and '${provider.id}' claim host-login for host '${host}'`, + ); + } + byHost.set(host, provider.id); + } + } + return byHost; +} +const NATIVE_PROVIDER_BY_HOST = buildNativeProviderByHost(PROVIDER_REGISTRY); + const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); const own = (value, key) => plain(value) && Object.hasOwn(value, key); @@ -61,22 +88,30 @@ export function migrateIntegrationConfig(config = {}, _options = {}) { ? structuredClone(providers.hosts) : structuredClone(existing.hosts ?? {}); const enabled = Object.entries(hosts).filter(([, on]) => on).map(([host]) => host); - const defaults = { claude: 'anthropic', codex: 'openai' }; const priorBindings = mergeBindings( Array.isArray(existing.bindings) ? existing.bindings : [], Array.isArray(providers.bindings) ? providers.bindings : [], ); const seenHosts = new Set(priorBindings.map((binding) => binding.host)); - const inferred = enabled.filter((host) => !seenHosts.has(host)).map((host) => ({ - id: `${defaults[host] ?? 'unknown'}-via-${host}`, - host, - provider: defaults[host] ?? null, - model: null, - transport: 'native', - endpoint: null, - provenance: defaults[host] ? 'inferred' : 'unknown', - managedBy: 'unknown', - })); + // A host with no registered native provider (F-13) gets no inferred + // binding at all — nothing is restamped on any load, and it stays that + // way until a real binding is declared for it. + const inferred = enabled + .filter((host) => !seenHosts.has(host)) + .map((host) => { + const provider = NATIVE_PROVIDER_BY_HOST.get(host); + return provider ? { + id: `${provider}-via-${host}`, + host, + provider, + model: null, + transport: 'native', + endpoint: null, + provenance: 'inferred', + managedBy: 'unknown', + } : null; + }) + .filter((binding) => binding !== null); const ownership = structuredClone(existing.ownership ?? {}); const reverseMarker = 'rufloCodexMcp'; const hasLegacyCodex = (own(providers, 'codexMcp') && providers.codexMcp != null) diff --git a/src/lib/qeCourt.mjs b/src/lib/qeCourt.mjs index d219b4c..ec38e60 100644 --- a/src/lib/qeCourt.mjs +++ b/src/lib/qeCourt.mjs @@ -8,6 +8,21 @@ // protocol itself here — this module only reads/defaults the `routing` block // of an EXISTING .claude/skills/qe-court/config.json; it never creates the // file and never touches any other key in it. +// +// Deliberate fail-closed divergence from upstream referee.ts (F-11): +// unregistered provider ids are never collapsed into one shared 'unknown' +// vendor — each keeps a distinct `unregistered:` tag so two different +// unrecognized providers never spuriously "share" a vendor for the purpose +// of *counting* distinct vendors. validatePanel applies the same "can't +// prove independence" epistemics symmetrically to both checks it runs: +// - vendor-diversity: unregistered vendors are excluded from the count +// entirely, so they can never help satisfy minVendors. +// - writerIsNeverJuror: an unregistered writer and an unregistered jury +// are treated as COULD-BE-colluding (flagged) even when their tags +// differ, because ak can't prove two ids it doesn't recognize are +// actually different vendors. +// Net effect: ak is never looser than a model where every unrecognized id +// shared one 'unknown' vendor — it may flag stricter, but never the reverse. import path from 'node:path'; import { readJson } from './settings.mjs'; import { installedVersion, cmpVersions } from './versions.mjs'; @@ -20,7 +35,10 @@ export function qeCourtShipped() { return !!v && cmpVersions(v, QE_COURT_MIN_VERSION) >= 0; } -/** Map a provider id to its coarse vendor — ported from qe-court's referee.js. */ +/** Map a provider id to its coarse vendor — ported from qe-court's referee.js. + * Known prefixes are byte-identical to upstream; an unrecognized id gets its + * own `unregistered:` tag instead of collapsing into one 'unknown' + * bucket (F-11 — see module comment). */ export function vendorOf(providerId) { const p = String(providerId).toLowerCase(); if (p.startsWith('claude')) return 'claude'; @@ -28,7 +46,7 @@ export function vendorOf(providerId) { if (p === 'codex' || p === 'openai' || p.startsWith('gpt') || p.startsWith('o3') || p.startsWith('o4')) return 'gpt'; if (p.startsWith('openrouter')) return 'openrouter'; if (p === 'ollama' || p === 'local') return 'local'; - return 'unknown'; + return `unregistered:${p}`; } /** Flatten config.json's `routing` map (role -> {provider, model?}) into the @@ -46,7 +64,11 @@ export function panelFromRouting(routing) { export function validatePanel(panel, policy = {}) { const minVendors = policy.minVendors ?? policy.minDistinctVendors ?? 2; const violations = []; - const vendorsSeated = new Set(panel.map((s) => vendorOf(s.provider))); + // unregistered vendors can never help satisfy minVendors — ak can't prove + // two unrecognized ids are independent vendors (F-11 fail-closed divergence). + const vendorsSeated = new Set( + panel.map((s) => vendorOf(s.provider)).filter((v) => !v.startsWith('unregistered:')), + ); if (vendorsSeated.size < minVendors) violations.push('vendor-diversity'); const jury = panel.find((s) => s.role === 'jury'); const writerLike = panel.filter((s) => s.role === 'defense' || s.role === 'writer'); @@ -54,7 +76,17 @@ export function validatePanel(panel, policy = {}) { violations.push('missing-jury'); } else if (policy.writerIsNeverJuror !== false) { const juryVendor = vendorOf(jury.provider); - if (writerLike.some((w) => vendorOf(w.provider) === juryVendor)) violations.push('writerIsNeverJuror'); + const juryUnregistered = juryVendor.startsWith('unregistered:'); + // Same vendor tag always collides. Two DIFFERENT unregistered tags also + // collide (fail closed) — ak can't prove an unrecognized writer id and + // an unrecognized jury id are actually different vendors, so it must + // assume they could be the same one rather than silently trust they + // aren't (mirrors the vendor-diversity exclusion above). + const collides = writerLike.some((w) => { + const writerVendor = vendorOf(w.provider); + return writerVendor === juryVendor || (juryUnregistered && writerVendor.startsWith('unregistered:')); + }); + if (collides) violations.push('writerIsNeverJuror'); } return violations; } diff --git a/tests/kit/integration-config.test.mjs b/tests/kit/integration-config.test.mjs index f0c2749..7cebf95 100644 --- a/tests/kit/integration-config.test.mjs +++ b/tests/kit/integration-config.test.mjs @@ -107,7 +107,10 @@ test('legacy host routes never manufacture observed provider provenance', () => const migrated = migrateIntegrationConfig(structuredClone(legacyDual)); const route = migrated.integrations.bindings.find((binding) => binding.host === 'codex'); assert.ok(route, 'migration should expose a normalized binding for an enabled legacy host'); - assert.ok(['inferred', 'unknown'].includes(route.provenance)); + // the 'unknown' provenance path no longer exists post-F-13: a host with no + // registered native provider gets no binding at all, so any binding that + // IS produced is always 'inferred'. + assert.equal(route.provenance, 'inferred'); assert.notEqual(route.provenance, 'observed'); }); @@ -127,8 +130,8 @@ test('legacy OpenCode ownership markers migrate canonically without inferring a assert.equal(Object.hasOwn(migrated.providers, key), false); } const binding = migrated.integrations.bindings.find(({ host }) => host === 'opencode'); - assert.equal(binding.provider, null); - assert.equal(binding.provenance, 'unknown'); + assert.equal(binding, undefined, + 'opencode has no host-login native provider in the registry, so migration infers no binding for it (F-13)'); }); test('legacy Codex ownership markers migrate together and are retired', () => { @@ -244,6 +247,54 @@ test('migration never persists API keys found in process environment', () => { } }); +test('migrating claude+codex+opencode infers registry-native bindings and nothing for opencode', () => { + const migrated = migrateIntegrationConfig({ + providers: { hosts: { claude: true, codex: true, opencode: true } }, + }); + const byHost = Object.fromEntries(migrated.integrations.bindings.map((b) => [b.host, b])); + assert.equal(byHost.claude.id, 'anthropic-via-claude'); + assert.equal(byHost.claude.provider, 'anthropic'); + assert.equal(byHost.claude.provenance, 'inferred'); + assert.equal(byHost.codex.id, 'openai-via-codex'); + assert.equal(byHost.codex.provider, 'openai'); + assert.equal(byHost.codex.provenance, 'inferred'); + assert.equal(Object.hasOwn(byHost, 'opencode'), false, + 'opencode has no host-login native provider registered, so it gets no inferred binding'); +}); + +test('inferring native bindings is idempotent — a second migration pass never restamps', () => { + const first = migrateIntegrationConfig({ + providers: { hosts: { claude: true, codex: true, opencode: true } }, + }); + const second = migrateIntegrationConfig(structuredClone(first)); + assert.deepEqual(second, first); +}); + +test('a prior user binding for a host wins over the registry-derived default', () => { + const priorBinding = { + id: 'openrouter-via-codex', + host: 'codex', + provider: 'openrouter', + model: 'openai/gpt-5.4', + transport: 'native', + endpoint: null, + provenance: 'user', + managedBy: 'user', + }; + const migrated = migrateIntegrationConfig({ + providers: { hosts: { claude: true, codex: true }, bindings: [priorBinding] }, + }); + const codexBindings = migrated.integrations.bindings.filter((b) => b.host === 'codex'); + assert.equal(codexBindings.length, 1); + assert.deepEqual(codexBindings[0], priorBinding); +}); + +test('migrateIntegrationConfig handles an empty hosts map without inferring anything', () => { + const migrated = migrateIntegrationConfig({ providers: { hosts: {} } }); + assert.deepEqual(migrated.integrations.bindings, []); + assert.deepEqual(migrated.integrations.hosts, {}); +}); + test('endpoint validation accepts loopback HTTP and remote HTTPS', () => { for (const endpoint of [ 'http://127.0.0.1:11434', diff --git a/tests/kit/qeCourt.test.mjs b/tests/kit/qeCourt.test.mjs index aeb270b..b08e9a6 100644 --- a/tests/kit/qeCourt.test.mjs +++ b/tests/kit/qeCourt.test.mjs @@ -26,8 +26,9 @@ test('vendorOf classifies codex/openai/gpt/o3/o4 as the gpt vendor', () => { } }); -test('vendorOf classifies unknown providers as unknown', () => { - assert.equal(vendorOf('some-new-thing'), 'unknown'); +test('vendorOf tags an unregistered provider with its own id instead of a shared "unknown" bucket', () => { + assert.equal(vendorOf('some-new-thing'), 'unregistered:some-new-thing'); + assert.equal(vendorOf('hermes-something'), 'unregistered:hermes-something'); }); // panelFromRouting @@ -102,6 +103,76 @@ test('validatePanel binds the shipped option names and requires a jury', () => { ); }); +test('validatePanel never lets two distinct unregistered providers satisfy minVendors, and treats them as possibly-colluding', () => { + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + // distinct unregistered ids never count toward vendor-diversity, AND ak + // can't prove they're different vendors either — so writerIsNeverJuror + // fails closed too (this is the reviewer's minimal repro for F-11 round 2). + assert.deepEqual(validatePanel(panel), ['vendor-diversity', 'writerIsNeverJuror']); +}); + +test('validatePanel does NOT trip writerIsNeverJuror when only the jury is unregistered and all writers are registered', () => { + const panel = [ + { role: 'defense', provider: 'claude-code' }, + { role: 'jury', provider: 'hermes-a' }, + ]; + // matches old shared-'unknown'-vendor behavior: a registered writer vendor + // can never equal an unregistered jury tag, so no collusion is assumed. + assert.deepEqual(validatePanel(panel), ['vendor-diversity']); +}); + +test('direction of divergence: ak is never looser than the old shared-"unknown"-vendor model', () => { + // Reviewer's minimal repro: under the OLD code, every unrecognized id + // mapped to the single literal 'unknown' vendor, so vendorOf(jury) === + // vendorOf(defense) always held here and tripped writerIsNeverJuror. The + // per-id `unregistered:` tagging must not silently drop that + // violation — new violations must be a superset of what old code found. + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + const oldViolations = ['vendor-diversity', 'writerIsNeverJuror']; + const newViolations = validatePanel(panel); + for (const violation of oldViolations) { + assert.ok(newViolations.includes(violation), `expected new violations to retain '${violation}'`); + } +}); + +test('validatePanel: minVendors 0 never flags vendor-diversity, even for an all-unregistered panel', () => { + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + assert.deepEqual(validatePanel(panel, { minVendors: 0, writerIsNeverJuror: false }), []); +}); + +test('validatePanel: minVendors 1 flags vendor-diversity when only unregistered vendors are seated', () => { + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + assert.deepEqual(validatePanel(panel, { minVendors: 1, writerIsNeverJuror: false }), ['vendor-diversity']); +}); + +test('validatePanel handles an empty panel: vendor-diversity and missing-jury both flagged', () => { + assert.deepEqual(validatePanel([]), ['vendor-diversity', 'missing-jury']); +}); + +test('validatePanel still trips writerIsNeverJuror when writer and jury share the same unregistered id', () => { + const panel = [ + { role: 'defense', provider: 'hermes-x' }, + { role: 'prosecutor.a', provider: 'codex' }, + { role: 'prosecutor.b', provider: 'claude-code' }, + { role: 'jury', provider: 'hermes-x' }, + ]; + // registered gpt + claude vendors satisfy minVendors on their own, so this + // isolates writerIsNeverJuror: same unregistered id seated as writer and juror. + assert.deepEqual(validatePanel(panel), ['writerIsNeverJuror']); +}); + test('validatePanel honors an explicit writerIsNeverJuror:false policy', () => { const panel = [ { role: 'defense', provider: 'claude-code' }, From e70f0f94a6a7891514e4173315bd8a94d1060055 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 14 Aug 2026 18:53:57 -0400 Subject: [PATCH 5/6] fix: live events carry unknown hosts honestly instead of rewriting them to internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 consistency hygiene (F-09): createLiveEvent resolves hosts through one policy — a known host passes, a safely-shaped novel id passes verbatim, and anything else becomes the explicit unknown-host bucket, never the retired 'internal' label. Declared actor/target hosts run through the same policy and inherit the session host only when absent, so an actor on a novel host is never silently attributed to the session's host. The structured adapter stops pre-filtering hosts through its own allowlists and passes raw values to the schema, the single resolver. --- src/lib/live/event-schema.mjs | 26 ++++++++++++-- src/lib/live/index.mjs | 2 +- src/lib/live/structured-adapter.mjs | 15 ++++++-- tests/kit/live-adapters.test.mjs | 54 +++++++++++++++++++++++++++++ tests/kit/live-core.test.mjs | 53 ++++++++++++++++++++++++++-- 5 files changed, 141 insertions(+), 9 deletions(-) diff --git a/src/lib/live/event-schema.mjs b/src/lib/live/event-schema.mjs index 0eb0b98..ace0f84 100644 --- a/src/lib/live/event-schema.mjs +++ b/src/lib/live/event-schema.mjs @@ -3,6 +3,12 @@ export const LIVE_SCHEMA_VERSION = 2; const HOSTS = new Set(['claude', 'codex', 'opencode', 'internal']); // `internal` is read-only historical vocabulary. Live-source registration still // admits only ruflo/aqe, and no current writer emits the retired dual-run label. +// A host id outside this closed set is not folded into `internal` — that would +// misrecord a real, novel assistant as ak-internal activity. A safely-shaped +// unknown id (see SAFE_HOST_ID) passes through verbatim so per-id truth is +// preserved; anything unsafe or non-string becomes the explicit 'unknown-host' +// bucket, which can never collide with 'internal' or a real host. +const SAFE_HOST_ID = /^[a-z][a-z0-9-]{0,31}$/; const SURFACES = new Set(['native', 'ruflo', 'aqe', 'plugin', 'skill', 'internal']); const CONFIDENCE = new Set(['observed', 'configured', 'correlated', 'inferred', 'unknown', 'assumed', 'planned']); const PROVIDER_PROVENANCE = new Set(['observed', 'configured', 'inferred', 'unknown']); @@ -49,6 +55,15 @@ function count(value) { return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null; } +// 'unknown-host' is itself SAFE_HOST_ID-shaped, so a host literally named +// 'unknown-host' is indistinguishable from the bucket. Accepted: no consumer +// branches on bucketed-vs-declared, and inventing an out-of-grammar sentinel +// risks its own key-syntax interactions downstream. +export function resolveHost(value) { + if (HOSTS.has(value)) return value; + return typeof value === 'string' && SAFE_HOST_ID.test(value) ? value : 'unknown-host'; +} + function safeWorkspace(value) { if (!value || typeof value !== 'object') return null; const capturedAt = timestamp(value.capturedAt); @@ -104,7 +119,7 @@ export function createLiveEvent(input, { now = () => new Date().toISOString() } if (!sessionId || !action || !actorId) { throw new TypeError('live event requires sessionId, action and actor.id'); } - const host = HOSTS.has(input.host) ? input.host : 'internal'; + const host = resolveHost(input.host); // Preserve the historical actor-level identity accepted from adapters while // exposing the axes at the event level. Provider intentionally has no host // fallback: a transcript proves its host, not which provider served it. @@ -150,7 +165,12 @@ export function createLiveEvent(input, { now = () => new Date().toISOString() } kind: actorKind, label: text(input.actor?.label, 96), role: text(input.actor?.role, 96), - host: HOSTS.has(input.actor?.host) ? input.actor.host : host, + // No declared host is a legitimate inference (the actor ran in the + // session's host); a declared-but-unrecognized host is not the same + // thing and must run through the same resolution as the event-level + // host, or it would silently misattribute a novel actor to whichever + // host the session happened to be. + host: input.actor?.host == null ? host : resolveHost(input.actor.host), provider, model, }, @@ -160,7 +180,7 @@ export function createLiveEvent(input, { now = () => new Date().toISOString() } kind: ACTOR_KINDS.has(input.target.kind) ? input.target.kind : 'agent', label: text(input.target.label, 96), role: text(input.target.role, 96), - host: HOSTS.has(input.target.host) ? input.target.host : host, + host: input.target.host == null ? host : resolveHost(input.target.host), } : null, status: STATUS.has(input.status) ? input.status : 'unknown', signal: { diff --git a/src/lib/live/index.mjs b/src/lib/live/index.mjs index 766339f..17cfae1 100644 --- a/src/lib/live/index.mjs +++ b/src/lib/live/index.mjs @@ -1,4 +1,4 @@ -export { LIVE_SCHEMA_VERSION, createLiveEvent } from './event-schema.mjs'; +export { LIVE_SCHEMA_VERSION, createLiveEvent, resolveHost } from './event-schema.mjs'; export { LiveReplayStream } from './replay-stream.mjs'; export { emptyLiveProjection, reduceLiveEvent, serializeLiveProjection, sweepLiveProjection, diff --git a/src/lib/live/structured-adapter.mjs b/src/lib/live/structured-adapter.mjs index 8e45c0d..2c57138 100644 --- a/src/lib/live/structured-adapter.mjs +++ b/src/lib/live/structured-adapter.mjs @@ -20,8 +20,10 @@ export function adaptStructuredEvent(record, { const sessionHost = record.sessionHost ?? record.session_host ?? record.host; if (![sid, actorId, action].every((value) => typeof value === 'string' && value)) return []; return [createLiveEvent({ - sessionId: sid, host: ['claude', 'codex', 'opencode'].includes(sessionHost) - ? sessionHost : 'internal', + // createLiveEvent already resolves input.host through the same policy + // (known host, safely-shaped novel id, or 'unknown-host'); passing the + // raw value through avoids resolving it here a second time. + sessionId: sid, host: sessionHost, surface, project: project ?? record.project, // The configured source owns repository attribution. A structured record // cannot supply an arbitrary opaque key that collides with another repo. @@ -32,13 +34,20 @@ export function adaptStructuredEvent(record, { id: actorId, kind: record.kind === 'gate' ? 'gate' : (record.kind === 'subagent' ? 'subagent' : 'agent'), role: record.role, - host: ['claude', 'codex', 'opencode'].includes(actorHost) ? actorHost : 'internal', + // createLiveEvent resolves a *declared* actor host through the same + // policy as the session host (known id, safe novel id, or + // 'unknown-host'), and only inherits the session host when it's + // undefined here — so raw passthrough already gets full per-id truth; + // pre-resolving would just run the same regex twice. + host: actorHost, provider: record.provider, model: record.model, }, action, target: record.targetId ? { id: record.targetId, kind: record.targetKind, role: record.targetRole ?? record.target_role, + // Same resolution contract as actor.host above: declared values + // are resolved, undefined inherits the session host. host: record.targetHost ?? record.target_host, } : null, status: record.status, diff --git a/tests/kit/live-adapters.test.mjs b/tests/kit/live-adapters.test.mjs index 2ce5d37..8fcde40 100644 --- a/tests/kit/live-adapters.test.mjs +++ b/tests/kit/live-adapters.test.mjs @@ -173,6 +173,60 @@ test('AQE court evidence preserves leader and member execution hosts independent assert.equal(event.signal.kind, 'relationship'); }); +test('AQE court evidence resolves a novel safe actor/target host instead of inheriting the session host', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'court-2', sessionHost: 'claude', agentId: 'court-lead-2', + actorHost: 'hermes', kind: 'agent', role: 'court-lead', + action: 'court.member.seated', targetId: 'jury-hermes', targetKind: 'subagent', + targetRole: 'jury', targetHost: 'hermes', status: 'running', + }, { surface: 'aqe', adapter: 'aqe-fixture', observedAt: now }); + assert.equal(event.host, 'claude'); + assert.equal(event.actor.host, 'hermes'); + assert.equal(event.target.host, 'hermes'); +}); + +test('AQE court evidence buckets an unsafe actor/target host as unknown-host, not the session host', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'court-3', sessionHost: 'claude', agentId: 'court-lead-3', + actorHost: 'Hermes!', kind: 'agent', role: 'court-lead', + action: 'court.member.seated', targetId: 'jury-unsafe', targetKind: 'subagent', + targetRole: 'jury', targetHost: 'Hermes!', status: 'running', + }, { surface: 'aqe', adapter: 'aqe-fixture', observedAt: now }); + assert.equal(event.host, 'claude'); + assert.equal(event.actor.host, 'unknown-host'); + assert.equal(event.target.host, 'unknown-host'); +}); + +test('AQE court evidence lets an actor/target with no declared host inherit the session host', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'court-4', sessionHost: 'claude', agentId: 'court-lead-4', + kind: 'agent', role: 'court-lead', + action: 'court.member.seated', targetId: 'jury-absent', targetKind: 'subagent', + targetRole: 'jury', status: 'running', + }, { surface: 'aqe', adapter: 'aqe-fixture', observedAt: now }); + assert.equal(event.host, 'claude'); + assert.equal(event.actor.host, 'claude'); + assert.equal(event.target.host, 'claude'); +}); + +test('structured adapter passes a novel safely-shaped host id through, never internal', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'hermes-1', sessionHost: 'hermes', agentId: 'hermes-agent', + actorHost: 'hermes', action: 'session.started', status: 'running', + }, { surface: 'ruflo', adapter: 'ruflo-hook', observedAt: now }); + assert.equal(event.host, 'hermes'); + assert.equal(event.actor.host, 'hermes'); +}); + +test('structured adapter buckets an unsafe-shaped host id as unknown-host, never internal', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'bad-1', sessionHost: 'Hermes!', agentId: 'bad-agent', + actorHost: 'Hermes!', action: 'session.started', status: 'running', + }, { surface: 'ruflo', adapter: 'ruflo-hook', observedAt: now }); + assert.equal(event.host, 'unknown-host'); + assert.equal(event.actor.host, 'unknown-host'); +}); + test('explicit tool names classify skill, plugin and MCP nodes without inspecting input', () => { for (const [name, kind] of [ ['Skill', 'skill'], ['plugin:review', 'plugin'], ['mcp__ruflo__swarm_status', 'mcp'], diff --git a/tests/kit/live-core.test.mjs b/tests/kit/live-core.test.mjs index be086f5..17f8338 100644 --- a/tests/kit/live-core.test.mjs +++ b/tests/kit/live-core.test.mjs @@ -7,8 +7,8 @@ import path from 'node:path'; import { createLiveEvent, LiveReplayStream, emptyLiveProjection, inspectGitWorkspace, parseGitNumstat, - reduceLiveEvent, resolveProjectIdentity, resolveProjectLabel, serializeLiveProjection, - sweepLiveProjection, stableProjectKey, WorkspaceSnapshotStore, + reduceLiveEvent, resolveHost, resolveProjectIdentity, resolveProjectLabel, + serializeLiveProjection, sweepLiveProjection, stableProjectKey, WorkspaceSnapshotStore, } from '../../src/lib/live/index.mjs'; const base = (over = {}) => ({ @@ -59,6 +59,55 @@ test('event schema preserves retired compatibility provenance as internal', () = assert.equal(createLiveEvent(base({ surface: 'dual-run' })).surface, 'internal'); }); +test('event schema passes a novel but safely-shaped host id through verbatim', () => { + assert.equal(createLiveEvent(base({ host: 'hermes' })).host, 'hermes'); +}); + +test('event schema buckets an unsafe-shaped host id as unknown-host, never internal', () => { + assert.equal(createLiveEvent(base({ host: 'Hermes!' })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: 'my host' })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: 'a'.repeat(33) })).host, 'unknown-host'); +}); + +test('event schema buckets a missing or malformed host as unknown-host, never internal', () => { + assert.equal(createLiveEvent(base({ host: undefined })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: null })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: 123 })).host, 'unknown-host'); +}); + +test('event schema keeps known hosts and the historical internal input unchanged', () => { + assert.equal(createLiveEvent(base({ host: 'claude' })).host, 'claude'); + assert.equal(createLiveEvent(base({ host: 'codex' })).host, 'codex'); + assert.equal(createLiveEvent(base({ host: 'opencode' })).host, 'opencode'); + assert.equal(createLiveEvent(base({ host: 'internal' })).host, 'internal'); +}); + +// The bucket is itself SAFE_HOST_ID-shaped by construction (see event-schema.mjs); +// this pins the accepted ambiguity between a declared host literally named +// 'unknown-host' and a value that was bucketed into it. +test('resolveHost is idempotent on its own unknown-host bucket', () => { + assert.equal(resolveHost('unknown-host'), 'unknown-host'); +}); + +test('event schema resolves a declared actor/target host instead of inheriting the session host, but still inherits when one is absent', () => { + assert.equal(createLiveEvent(base({ + actor: { id: 's1', kind: 'session', host: 'hermes' }, + })).actor.host, 'hermes'); + assert.equal(createLiveEvent(base({ + actor: { id: 's1', kind: 'session', host: 'Hermes!' }, + })).actor.host, 'unknown-host'); + assert.equal(createLiveEvent(base({ + actor: { id: 's1', kind: 'session' }, + })).actor.host, 'claude'); + + const target = (host) => createLiveEvent(base({ + action: 'tool.started', target: { id: 'call', kind: 'tool', host }, + })).target.host; + assert.equal(target('hermes'), 'hermes'); + assert.equal(target('Hermes!'), 'unknown-host'); + assert.equal(target(undefined), 'claude'); +}); + test('event schema assigns privacy-safe project and host-qualified session identities', () => { const event = createLiveEvent(base({ sessionId: 'shared:id', From 99bb6099abc9d31156ed55a647e4003a3c9fce58 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 14 Aug 2026 18:54:15 -0400 Subject: [PATCH 6/6] docs: align citations and record the Phase 0 decisions where they belong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-anchor eight usage-index.mjs line citations that drifted past the doc-citation gate's tolerance; note in PROVIDERS.md that ak host status warns on malformed kit.json bindings (current-state phrasing only — history stays in the ADRs); ADR-0015 records the statuslineSupported removal and ADR-0016 records the Phase 0 registry-level changes (enabledByDefault, terminal observability axis, validateBinding wiring, registry-derived migrator defaults). --- docs/PROVIDERS.md | 5 ++++- docs/TRANSCRIPTS.md | 12 ++++++------ docs/USAGE-SCORECARD-METRICS.md | 4 ++-- docs/adr/0015-managed-codex-native-statusline.md | 5 +++++ .../0016-capability-driven-integration-adapters.md | 11 ++++++++++- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 568657b..b2defa6 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -42,7 +42,10 @@ This capability model is [ADR-0016](adr/0016-capability-driven-integration-adapters.md) (Accepted); the controls below implement it. `ak host` owns execution-host lifecycle and selection (`status`, `pick`, `refresh`, and `off`), with `ak x host` as its plumbing spelling. Inference providers and bindings remain -separate axes even though some provider controls share that workflow. +separate axes even though some provider controls share that workflow. `ak host status` also +checks every binding declared in `kit.json` and prints a warning naming any entry with an +unknown host, unknown provider, or unsupported transport — warnings only; nothing is changed +or removed on your behalf. --- diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index c94b5e0..6de247d 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -175,15 +175,15 @@ and image-only pastes get the right kind" (the two edges). ## 4. The `readSession` pipeline — how one session becomes a payload -`readSession(id, opts)` (`usage-index.mjs:1350-1438`) is the only way +`readSession(id, opts)` (`usage-index.mjs:1457-1513`) is the only way transcript content leaves the module, and every step is a gate: ### 4.1 Locate, contain, bound 1. **Id grammar before any filesystem access** — `VALID_ID` (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:83`) rejects traversal - shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1382-1386`). -2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1392`), + shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1399-1406`). +2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1409`), consulting the scan cache when present but never requiring it — `readSession` works with no prior `buildIndex`. 3. **Realpath containment** (`usage-index.mjs:1388-1402`) — the resolved file @@ -198,8 +198,8 @@ transcript content leaves the module, and every step is a gate: ### 4.2 Parse and price The file is parsed with `withTurns: true` by the provider's parser -(`usage-index.mjs:1483-1490`), and `meta` is assembled -(`usage-index.mjs:1467-1495`) with the same fields the Sessions view rows +(`usage-index.mjs:1502-1509`), and `meta` is assembled +(`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 @@ -211,7 +211,7 @@ Every turn body is passed through `maskSecrets` (`usage-index.mjs:196` — the 23 secret shapes) **server-side, before serialization**, then length-capped at `MAX_TURN_CHARS` (40,000, `usage-index.mjs:77`) with the marker appended -(`usage-index.mjs:1530-1540`). Two invariants: +(`usage-index.mjs:1552-1561`). Two invariants: - **Presence is the signal.** `truncated`/`originalChars` are emitted only when the slice fired, so a complete turn cannot be misread as abridged. diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index d27e0ec..0a0e32c 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -467,7 +467,7 @@ session that runs from 23:58 local to 00:05 local is billed to the day its *first* row landed on (test: `tests/kit/usage-index.test.mjs:634`, "a session that opens before midnight is counted on its first billed day"). Accumulation: -`byDay[row.day].cost += rowCost` (`usage-index.mjs:899`). Bar height: +`byDay[row.day].cost += rowCost` (`usage-index.mjs:1020`). Bar height: `h = maxDay ? max(2, cost/maxDay*100) : 2` (`dashboard/client.mjs`) — every non-empty day gets a visually nonzero bar (floor of 2%), so a very cheap day is never rendered as invisible. @@ -964,7 +964,7 @@ commit `540be18` on this branch. Claude's parser passes `responses: 1` per assistant turn (`usage-index.mjs:556`, the current equivalent), but Codex's call passed no such field at all. Because `byModel[model].responses` is summed -directly from each usage row's `responses` field (`usage-index.mjs:974`, +directly from each usage row's `responses` field (`usage-index.mjs:1022`, `m.responses += row.responses`), **every** Codex model in §10's "Models in Play" list displayed `0 resp` regardless of real token/cost volume or actual `agent_message` count. **Fix:** `parseCodex` now passes `responses: diff --git a/docs/adr/0015-managed-codex-native-statusline.md b/docs/adr/0015-managed-codex-native-statusline.md index ec311ba..dde03f6 100644 --- a/docs/adr/0015-managed-codex-native-statusline.md +++ b/docs/adr/0015-managed-codex-native-statusline.md @@ -2,6 +2,11 @@ - **Status:** Accepted - **Date:** 2026-07-28 +- **Updated:** 2026-08-14 +- **Update note:** The boolean `statuslineSupported` consumer this ADR's context + describes was removed as dead code in the Phase 0 consistency pass (finding + F-07): it had zero call sites. The registry capability `commandStatusline` is + the surviving vocabulary for this distinction. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0006](0006-primary-host-and-ambidextrous-mirroring.md), diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index 8459156..29e0416 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -3,12 +3,21 @@ - **Status:** Accepted; compatibility clauses superseded by [ADR-0020](0020-ga-stable-surfaces.md) - **Date:** 2026-07-28 -- **Updated:** 2026-08-04 +- **Updated:** 2026-08-14 - **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, persisted fields, and adapter bootstrap. ADR-0023 now requires each host adapter to declare its setup trust posture and changes for host-neutral preflight. + Phase 0 consistency pass (2026-08-14): host adapters gained a required + `enabledByDefault` boolean and the three enabled-host default literals now + derive from it via `defaultHostMap()` (F-15); the `observability` axis is + recorded as terminal — validation metadata with referential integrity only, + deliberately not a dispatch surface, no collector loop exists (F-12); the + non-throwing `validateBinding` is wired into `ak host status` as per-entry + 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). - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md),