Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,14 @@ Defaults (all overridable; your edits are marked `custom` and never re-seeded):
`ak host pick --help` prints this list too. Tuning is per-route and reversible: hand-edit
`kit.json` `providers.dualRouting`, pass `--route`, or `ak host off` to clear it entirely.

**Disabling is complete, not just a flag change.** `ak host pick --host <set>` treats the
set as authoritative. Excluding a routing host removes it from persisted routes and escalation
ladders before AQE is reprojected; seeded entries are removed silently, while a user-pinned route
prints a warning naming the disabled host. It also removes stale agentic-kit-curated AQE overrides
while preserving foreign override keys. Excluding Codex additionally retires only the two
marker-owned Codex MCP bridges; user-registered MCP servers are left alone. Re-enabling Codex
converges those bridges again.

`dualRouting` intentionally names a host and model, not an inference provider. Provider resolution
is a separate binding lookup; absent grounded evidence remains unknown or explicitly inferred.
Likewise, `ak dual run` continues to mean the Claude+Codex collaboration substrate. Multiple
Expand Down
20 changes: 19 additions & 1 deletion src/commands/x/provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
ensureRufloMcpInCodex, undoRufloMcpInCodex, detectAqeProviders, aqeProviderCredential, credentialGaps, fallbackSource,
collectIntegrationFacts,
} from '../../lib/providers.mjs';
import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, modelNote, ACTIVITIES } from '../../lib/routing.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 { runLifecycle } from '../../lib/adapters/lifecycle.mjs';
Expand Down Expand Up @@ -401,6 +401,9 @@ async function pick({ flags, cwd, pkgRoot }) {
let models = cfg.providers.models ?? [];
const prevPrimary = cfg.providers.primaryHost ?? DEFAULT_PRIMARY_HOST;
const oldPolicy = cfg.providers.dualRouting ?? {};
const prevCodex = !!cfg.providers?.hosts?.codex;
const codexMcpManaged = cfg.providers?.codexMcp === 'ak';
const rufloCodexManaged = cfg.providers?.rufloCodexMcp === 'ak';

const nonInteractive = flags.host !== undefined || flags['aqe-provider'] !== undefined
|| flags['aqe-fallback'] !== undefined || flags.provider !== undefined
Expand Down Expand Up @@ -543,6 +546,20 @@ async function pick({ flags, cwd, pkgRoot }) {
for (const w of warnings) warn(w);
cfg.providers.dualRouting = { ...cfg.providers.dualRouting, ...policy };
}
const prunedRoutes = pruneRoutesForHosts(cfg.providers.dualRouting, { hosts: routing });
cfg.providers.dualRouting = prunedRoutes.policy;
for (const message of prunedRoutes.warnings) warn(message);

// Codex owns two directional MCP bridges. Disable only the marker-owned
// bridges, matching OpenCode's receipt-based teardown semantics.
let codexRetired = null;
if (prevCodex && !cfg.providers.hosts.codex) {
const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged });
const rmcp = await undoRufloMcpInCodex(cwd, { managed: rufloCodexManaged });
cfg.providers.codexMcp = null;
cfg.providers.rufloCodexMcp = null;
codexRetired = { mcp, rmcp };
}
saveKitConfig(cfg);

// install any enabled host that is entirely absent (external installs untouched)
Expand Down Expand Up @@ -604,6 +621,7 @@ async function pick({ flags, cwd, pkgRoot }) {

const h = applyHosts(cfg, cwd);
(h.ok ? ok : fail)(`hosts: ${h.detail}`);
if (codexRetired) ok(`codex disabled: ${codexRetired.mcp.detail}; ${codexRetired.rmcp.detail}`);
if (primaryHost !== DEFAULT_PRIMARY_HOST) {
const alt = routing.filter((e) => e !== primaryHost).join(', ') || 'none';
ok(`primary host: ${primaryHost} (alternate: ${alt})`);
Expand Down
31 changes: 20 additions & 11 deletions src/lib/providers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { readJson, writeJsonWithBackup } from './settings.mjs';
import { installedVersion, cmpVersions } from './versions.mjs';
import * as paths from './paths.mjs';
import { bold, dim, cyan } from './output.mjs';
import { policyToAgentOverrides, seedDualRouting, resolveRoutes, routingSummary, divergedRoutes, ACTIVITIES, DEFAULT_PRIMARY_HOST, PRIMARY_HOSTS } from './routing.mjs';
import { configuredPolicyToAgentOverrides, seedDualRouting, resolveRoutes, routingSummary, divergedRoutes, ACTIVITIES, AGENT_ACTIVITY_MAP, DEFAULT_PRIMARY_HOST, PRIMARY_HOSTS } from './routing.mjs';
import { HOST_ADAPTERS } from './hosts.mjs';
import {
HOST_REGISTRY, PROVIDER_REGISTRY, normalizeIntegrationFacts,
Expand Down Expand Up @@ -418,13 +418,20 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) {
const policy = cfg.providers?.dualRouting ?? {};
const hasChain = chain.length > 0;
const hasPolicy = Object.keys(policy).length > 0;
if (!hasChain && !hasPolicy) return { ok: true, changed: false, detail: 'no aqe router config to apply' };
// Same repo-root resolution as settingsTarget — the three scope gates must
// never disagree about what "in a project" means (see paths.repoRoot).
const root = paths.repoRoot(cwd);
if (!root) return { ok: true, changed: false, detail: 'not a project — aqe router unmanaged' };
const file = aqeRouterFile(root);
const existing = readJson(file, {}) ?? {};
const priorOverrides = existing.agentOverrides ?? {};
const projected = configuredPolicyToAgentOverrides(policy);
const managedOverrideKeys = new Set(Object.keys(AGENT_ACTIVITY_MAP));
const staleOverrides = Object.keys(priorOverrides)
.filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected));
if (!hasChain && !hasPolicy && staleOverrides.length === 0) {
return { ok: true, changed: false, detail: 'no aqe router config to apply' };
}
const next = { ...existing };
next._managedBy = AQE_MANAGED_TAG;
const details = [];
Expand Down Expand Up @@ -454,14 +461,16 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) {
}
}

if (hasPolicy && aqeSupportsAgentOverrides()) {
if ((hasPolicy || staleOverrides.length) && aqeSupportsAgentOverrides()) {
// MERGE, don't replace: ak owns only the curated agent-types it projects;
// preserve foreign entries (aqe's own defaults or a hand-added agent). The
// projector drops non-constructible providers (mirrors sanitizeAgentOverrides)
// and only ever emits {provider, model} — no apiKey.
const projected = policyToAgentOverrides(policy);
next.agentOverrides = { ...(existing.agentOverrides ?? {}), ...projected };
details.push(`agentOverrides: ${Object.keys(projected).length} agents`);
next.agentOverrides = { ...priorOverrides };
for (const agent of staleOverrides) delete next.agentOverrides[agent];
Object.assign(next.agentOverrides, projected);
details.push(`agentOverrides: ${Object.keys(projected).length} agents`
+ (staleOverrides.length ? ` (${staleOverrides.length} stale ak entries pruned)` : ''));
wrote = true;
} else if (hasPolicy) {
details.push('agentOverrides: skipped (needs agentic-qe ≥ 3.13.1)');
Expand Down Expand Up @@ -603,9 +612,9 @@ export async function ensureCodexMcp(cfg, cwd = process.cwd()) {

/** Remove the project-scoped codex MCP server — ONLY when ak registered it
* (managed === true). Never tears down a server the user added themselves. */
export async function undoCodexMcp(cwd = process.cwd(), { managed = false } = {}) {
export async function undoCodexMcp(cwd = process.cwd(), { managed = false, runner = run } = {}) {
if (!managed) return { ok: true, changed: false, detail: 'codex MCP left as-is (not ak-registered)' };
const r = await run('claude', ['mcp', 'remove', 'codex', '-s', 'project'], { cwd });
const r = await runner('claude', ['mcp', 'remove', 'codex', '-s', 'project'], { cwd });
return { ok: true, changed: r.code === 0, detail: r.code === 0 ? 'codex MCP removed' : 'codex MCP not registered' };
}

Expand Down Expand Up @@ -636,10 +645,10 @@ export async function ensureRufloMcpInCodex(cfg, cwd = process.cwd()) {

/** Remove the ruflo MCP server from Codex — ONLY when ak registered it
* (managed === true). Never tears down a server the user added themselves. */
export async function undoRufloMcpInCodex(cwd = process.cwd(), { managed = false } = {}) {
export async function undoRufloMcpInCodex(cwd = process.cwd(), { managed = false, runner = run, haveFn = have } = {}) {
if (!managed) return { ok: true, changed: false, detail: 'ruflo→codex MCP left as-is (not ak-registered)' };
if (!(await have('codex'))) return { ok: true, changed: false, detail: 'codex CLI not installed' };
const r = await run('codex', ['mcp', 'remove', 'ruflo'], { cwd });
if (!(await haveFn('codex'))) return { ok: true, changed: false, detail: 'codex CLI not installed' };
const r = await runner('codex', ['mcp', 'remove', 'ruflo'], { cwd });
return { ok: true, changed: r.code === 0, detail: r.code === 0 ? 'ruflo MCP removed from codex' : 'ruflo→codex MCP not registered' };
}

Expand Down
42 changes: 42 additions & 0 deletions src/lib/routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,48 @@ export function policyToAgentOverrides(policy = {}, { agentMap = AGENT_ACTIVITY_
return overrides;
}

/** Project only explicitly persisted routes. This intentionally does not fill
* holes from dual-host defaults: a host-disable operation may remove an entry,
* and projecting a default for that hole would reintroduce the disabled host. */
export function configuredPolicyToAgentOverrides(policy = {}, { agentMap = AGENT_ACTIVITY_MAP } = {}) {
const overrides = {};
for (const [agent, act] of Object.entries(agentMap)) {
const route = policy[act];
if (!route) continue;
const provider = HOST_PROVIDER[route.host];
if (!AQE_CONSTRUCTIBLE_PROVIDERS.includes(provider)) continue;
overrides[agent] = { provider, model: route.model };
}
return overrides;
}

/** Remove disabled hosts from persisted routes and escalation ladders. Seeded
* entries are ak-owned and silent; user pins return actionable warnings. */
export function pruneRoutesForHosts(policy = {}, { hosts = HOSTS } = {}) {
const enabled = new Set(hosts);
const next = {};
const warnings = [];
const pruned = [];
for (const [activity, route] of Object.entries(policy)) {
const source = route?.source ?? 'user';
if (!enabled.has(route?.host)) {
pruned.push({ activity, kind: 'route', host: route?.host ?? null, source });
if (source !== 'seeded') warnings.push(`removed user route '${activity}' — host '${route?.host ?? 'unknown'}' is disabled`);
continue;
}
const before = Array.isArray(route.escalate) ? route.escalate : [];
const escalate = before.filter((rung) => enabled.has(rung?.host));
const removed = before.filter((rung) => !enabled.has(rung?.host));
for (const rung of removed) {
pruned.push({ activity, kind: 'escalation', host: rung?.host ?? null, source });
if (source !== 'seeded') warnings.push(`removed user escalation for '${activity}' — host '${rung?.host ?? 'unknown'}' is disabled`);
}
next[activity] = { ...route, ...(escalate.length ? { escalate } : {}) };
if (removed.length && !escalate.length) delete next[activity].escalate;
}
return { policy: next, pruned, warnings };
}

// ── Projection #2: dual-run collaboration config ────────────────────────────
// A template is an ordered DAG of activities; the policy fills host+model per
// node. Grounded in rUv's CollaborationTemplates (feature/security/refactor);
Expand Down
52 changes: 49 additions & 3 deletions tests/kit/provider-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ test('ak x provider status omits the dual-host guidance tips with only one host
function fakeBins(dir) {
const bin = path.join(dir, 'bin');
fs.mkdirSync(bin, { recursive: true });
for (const name of ['claude', 'opencode']) {
fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
for (const name of ['claude', 'codex', 'opencode']) {
fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nif [ -n "$AK_TEST_ARGV_LOG" ]; then printf "%s\\n" "$0 $*" >> "$AK_TEST_ARGV_LOG"; fi\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(bin, `${name}.cmd`), '@echo off\r\nexit /b 0\r\n');
}
return bin;
Expand Down Expand Up @@ -139,7 +139,7 @@ function pickSandbox({ hosts, providers = {} }) {
return { home, project, binDir, catalog };
}

function akPick(args, { cwd, home, binDir, catalog }, { input } = {}) {
function akPick(args, { cwd, home, binDir, catalog }, { input, env = {} } = {}) {
return spawnSync(process.execPath, [BIN, ...args], {
encoding: 'utf8',
cwd,
Expand All @@ -156,6 +156,7 @@ function akPick(args, { cwd, home, binDir, catalog }, { input } = {}) {
// never leak in and make detection non-deterministic.
PATH: [binDir, '/usr/bin', '/bin'].join(path.delimiter),
RUFLO_REPO: catalog,
...env,
},
});
}
Expand Down Expand Up @@ -270,6 +271,51 @@ test('a provider retune preserves every ownership marker (codex MCP bridges + op
}
});

test('excluding codex tears down only owned bridges and removes disabled routes', () => {
const sb = pickSandbox({
hosts: { claude: true, codex: true, opencode: false },
providers: {
codexMcp: 'ak', rufloCodexMcp: 'ak',
dualRouting: {
implementation: { host: 'codex', model: 'gpt-5.4', source: 'seeded' },
testing: { host: 'claude', model: 'claude-sonnet-5', source: 'user', escalate: [{ host: 'codex', model: 'gpt-5.4' }] },
},
},
});
try {
const r = akPick(['x', 'provider', 'pick', '--host', 'claude', '--yes'], sb);
assert.equal(r.status, 0, `disable failed\nstdout: ${r.stdout}\nstderr: ${r.stderr}`);
assert.match(r.stdout, /removed user escalation.*codex.*disabled/);
const p = kitJson(sb.home).providers;
assert.equal(p.codexMcp, null);
assert.equal(p.rufloCodexMcp, null);
assert.equal(p.dualRouting.implementation, undefined);
assert.deepEqual(p.dualRouting.testing, { host: 'claude', model: 'claude-sonnet-5', source: 'user' });
} finally {
rm(sb.home, sb.project);
}
});

test('excluding claude keeps codex-owned bridges while pruning claude routes', () => {
const sb = pickSandbox({
hosts: { claude: true, codex: true, opencode: false },
providers: {
codexMcp: 'ak', rufloCodexMcp: 'ak',
dualRouting: { review: { host: 'claude', model: 'claude-sonnet-5', source: 'seeded' } },
},
});
try {
const r = akPick(['x', 'provider', 'pick', '--host', 'codex', '--yes'], sb);
assert.equal(r.status, 0, r.stderr);
const p = kitJson(sb.home).providers;
assert.equal(p.dualRouting.review, undefined);
assert.equal(p.codexMcp, 'ak');
assert.equal(p.rufloCodexMcp, 'ak');
} finally {
rm(sb.home, sb.project);
}
});

test('interactive pick: installed opencode is displayed as an integration host but only ENTER-enabled when already on', () => {
const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } });
try {
Expand Down
44 changes: 43 additions & 1 deletion tests/kit/routing-projection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { applyAqeRouter, aqeRouterFile, undoAqeRouter, ensureCodexMcp, undoCodexMcp } from '../../src/lib/providers.mjs';
import { applyAqeRouter, aqeRouterFile, undoAqeRouter, ensureCodexMcp, undoCodexMcp, undoRufloMcpInCodex } from '../../src/lib/providers.mjs';
import { seedDualRouting } from '../../src/lib/routing.mjs';
import { _setGlobalRootForTest } from '../../src/lib/paths.mjs';

Expand Down Expand Up @@ -107,6 +107,29 @@ test('agentOverrides MERGES — a foreign entry survives (H1: never clobbered)',
rm(dir); rm(groot);
});

test('stale curated overrides are pruned while configured and foreign entries survive', () => {
const groot = fakeAqe('3.13.1');
const dir = tmpProject();
fs.mkdirSync(path.dirname(aqeRouterFile(dir)), { recursive: true });
fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({
_managedBy: 'agentic-kit',
agentOverrides: {
'qe-security-scanner': { provider: 'codex', model: 'gpt-5.4' },
'qe-code-reviewer': { provider: 'claude-code', model: 'claude-sonnet-5' },
'qe-custom-agent': { provider: 'ollama' },
},
}));
const res = applyAqeRouter(cfgWith({ dualRouting: {
review: { host: 'claude', model: 'claude-sonnet-5', source: 'user' },
} }), dir);
const disk = readDisk(dir);
assert.match(res.detail, /stale ak entries pruned/);
assert.equal(disk.agentOverrides['qe-security-scanner'], undefined);
assert.deepEqual(disk.agentOverrides['qe-code-reviewer'], { provider: 'claude-code', model: 'claude-sonnet-5' });
assert.deepEqual(disk.agentOverrides['qe-custom-agent'], { provider: 'ollama' });
rm(dir); rm(groot);
});

test('an invalid fallback chain does not block the agentOverrides projection (M3)', () => {
const groot = fakeAqe('3.13.1');
const dir = tmpProject();
Expand All @@ -129,6 +152,25 @@ test('codex MCP teardown is a no-op unless ak owns it (H2), and never shells whe
assert.match(ensure.detail, /not enabled/);
});

test('owned bridge teardown sends the precise safe argv on every platform', async () => {
const calls = [];
const runner = async (cmd, args, opts) => {
calls.push({ cmd, args, opts });
return { code: 0, stdout: '', stderr: '' };
};
const cwd = '/work/project';

const codex = await undoCodexMcp(cwd, { managed: true, runner });
const ruflo = await undoRufloMcpInCodex(cwd, { managed: true, runner, haveFn: async () => true });

assert.equal(codex.changed, true);
assert.equal(ruflo.changed, true);
assert.deepEqual(calls, [
{ cmd: 'claude', args: ['mcp', 'remove', 'codex', '-s', 'project'], opts: { cwd } },
{ cmd: 'codex', args: ['mcp', 'remove', 'ruflo'], opts: { cwd } },
]);
});

test('undoAqeRouter removes the ak-created file (agentOverrides included)', () => {
const groot = fakeAqe('3.13.1');
const dir = tmpProject();
Expand Down
19 changes: 18 additions & 1 deletion tests/kit/routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
ACTIVITIES, AK_ORIGINATED, DEFAULT_ROUTES, HOST_PROVIDER, SUBSCRIPTION_PROVIDERS,
AQE_CONSTRUCTIBLE_PROVIDERS, MODEL_CATALOG, MODEL_CATALOG_VERIFIED, modelChoices, formatModelHelp,
resolveRoutes, seedDualRouting, policyToAgentOverrides, routedVendors, routingSummary,
materializeRunPlan,
configuredPolicyToAgentOverrides, pruneRoutesForHosts, materializeRunPlan,
validateRoute, parseRouteSpecs,
DUAL_RUN_TEMPLATE_NAMES, policyToDualRunConfig, escalatePolicy,
} from '../../src/lib/routing.mjs';
Expand Down Expand Up @@ -106,6 +106,23 @@ test('overriding an activity flows through to its agent overrides', () => {
assert.equal(ov['qe-test-architect'].model, 'claude-sonnet-5');
});

test('configured projection never recreates a missing route from dual-host defaults', () => {
const ov = configuredPolicyToAgentOverrides({ review: { host: 'claude', model: 'claude-sonnet-5', source: 'user' } });
assert.equal(ov['qe-code-reviewer'].provider, 'claude-code');
assert.equal(ov['qe-test-architect'], undefined);
});

test('pruning disabled hosts drops primary routes and only invalid escalation rungs', () => {
const out = pruneRoutesForHosts({
implementation: { host: 'codex', model: 'gpt-5.4', source: 'seeded' },
testing: { host: 'claude', model: 'claude-sonnet-5', source: 'user', escalate: [{ host: 'codex', model: 'gpt-5.4' }] },
}, { hosts: ['claude'] });
assert.equal(out.policy.implementation, undefined);
assert.deepEqual(out.policy.testing, { host: 'claude', model: 'claude-sonnet-5', source: 'user' });
assert.equal(out.pruned.length, 2);
assert.deepEqual(out.warnings, ["removed user escalation for 'testing' — host 'codex' is disabled"]);
});

// ── Diversity + summary ─────────────────────────────────────────────────────

test('default routing spans at least two vendors (qe-court diversity)', () => {
Expand Down
Loading