diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 7a0cc95..d757b59 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -24,7 +24,7 @@ import { coherence as adbCoherence } from '../lib/agentdb.mjs'; import { readJson } from '../lib/settings.mjs'; import { have } from '../lib/exec.mjs'; import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, hostAuthState, bothHostsEnabled, aqeRouterFile, aqeSupportsAgentOverrides, credentialGaps, collectIntegrationFacts } from '../lib/providers.mjs'; -import { policyToAgentOverrides, routingSummary, divergedRoutes } from '../lib/routing.mjs'; +import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../lib/routing.mjs'; import { qeCourtShipped, readQeCourtConfig, validateCourtConfig } from '../lib/qeCourt.mjs'; import { drift as ruvectorDrift } from '../lib/ruvector.mjs'; import { statuslineDrift } from '../lib/codex-statusline.mjs'; @@ -545,11 +545,16 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } else { const desired = managedEnv(cfg); const envDrift = MANAGED_ENV_KEYS.some((k) => (k in desired ? env[k] !== desired[k] : k in env)); - // aqe fallback chain: on-disk llm-config.json must match kit.json order + // aqe fallback chain: on-disk llm-config.json must match kit.json order. + // Same scope gate as the writer (#129): applyAqeRouter anchors the file at + // repoRoot and declines outside a project, so the check must read the root + // and stay silent where sync would decline — a warn here would recommend a + // sync that cannot repair it. const chain = cfg.providers.aqeFallback ?? []; + const chainRoot = paths.repoRoot(cwd); let routerDrift = false; - if (chain.length) { - const disk = readJson(aqeRouterFile(cwd)); + if (chain.length && chainRoot) { + const disk = readJson(aqeRouterFile(chainRoot)); const diskOrder = (disk?.fallbackChain?.entries ?? []).map((e) => e.provider).join('→'); routerDrift = disk?._managedBy !== 'agentic-kit' || diskOrder !== chain.map((e) => e.provider).join('→'); } @@ -585,17 +590,27 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { const policy = cfg.routing?.routes ?? {}; if (Object.keys(policy).length) { const s = routingSummary(policy); - const want = policyToAgentOverrides(policy); + // The WRITER's projection (#129): applyAqeRouter materializes only + // explicitly persisted routes, so status must count and compare the same + // set — the resolved projection would demand entries sync never writes. + const want = configuredPolicyToAgentOverrides(policy); const base = `dual-host · ${s.total} activities (${s.custom} custom) → ${Object.keys(want).length} agent overrides`; if (!aqeSupportsAgentOverrides()) { rows.push(row('routing', 'info', `${base} · needs agentic-qe ≥ 3.13.1 to materialize`)); } else { - // resolve the repo root — applyAqeRouter writes at repoRoot(cwd), so a - // raw-cwd read from a subdir would false-warn "out of sync" (M2). - const disk = readJson(aqeRouterFile(paths.repoRoot(cwd) ?? cwd))?.agentOverrides ?? null; - const drift = !disk || JSON.stringify(disk) !== JSON.stringify(want); - if (drift) rows.push(row('routing', 'warn', `${base} — llm-config.json out of sync`, 'sync re-applies agentOverrides')); - else rows.push(row('routing', 'ok', base)); + // Same scope gate as the writer: applyAqeRouter anchors at repoRoot(cwd) + // and declines outside a project — a raw-cwd read from a subdir would + // false-warn "out of sync" (M2), and outside a project a warn would + // recommend a sync that cannot repair it (#129). + const root = paths.repoRoot(cwd); + if (!root) { + rows.push(row('routing', 'info', `${base} · not in a project — aqe router unmanaged here`)); + } else { + const overrides = readJson(aqeRouterFile(root))?.agentOverrides; + const drift = overrides == null || agentOverridesDrift(overrides, policy); + if (drift) rows.push(row('routing', 'warn', `${base} — llm-config.json out of sync`, 'sync re-applies agentOverrides')); + else rows.push(row('routing', 'ok', base)); + } } // Seeded pins vs today's defaults. Deliberately `info` and deliberately // "diverges from": which side wins is activity-dependent (a newer default diff --git a/src/lib/routing.mjs b/src/lib/routing.mjs index a2d4a3d..2f95f8b 100644 --- a/src/lib/routing.mjs +++ b/src/lib/routing.mjs @@ -489,6 +489,24 @@ export function configuredPolicyToAgentOverrides(policy = {}, { agentMap = AGENT return overrides; } +/** Drift predicate for the status↔sync contract (#129): does the on-disk + * `agentOverrides` object diverge from what applyAqeRouter would write for this + * policy? Judged by the WRITER's projection (configured routes only) and only + * over ak-managed keys, key-wise — foreign entries and JSON key order belong to + * the writer's merge domain and must never read as drift. */ +export function agentOverridesDrift(diskOverrides, policy = {}, { agentMap = AGENT_ACTIVITY_MAP } = {}) { + const disk = diskOverrides ?? {}; + const want = configuredPolicyToAgentOverrides(policy, { agentMap }); + for (const agent of Object.keys(agentMap)) { + const a = disk[agent]; + const b = want[agent]; + if (!a && !b) continue; + if (!a || !b) return true; + if (a.provider !== b.provider || (a.model ?? null) !== (b.model ?? null)) return true; + } + return false; +} + /** 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 } = {}) { diff --git a/tests/kit/status-aqe-drift.test.mjs b/tests/kit/status-aqe-drift.test.mjs new file mode 100644 index 0000000..4249f39 --- /dev/null +++ b/tests/kit/status-aqe-drift.test.mjs @@ -0,0 +1,156 @@ +// #129 — the status↔sync contract for the AQE router. `ak status` must judge +// `.agentic-qe/llm-config.json` by the SAME projection and the SAME project +// scope gate `ak sync` writes with (applyAqeRouter): a freshly synced state is +// never drift, and a location sync refuses to manage is never drift either. +// Anything else is a permanent warning whose own `fix` command cannot clear it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + sandboxHome, assertSandboxed, rmrf, + sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot, +} from './helpers/home-sandbox.mjs'; + +const HOME = sandboxHome('ak-status-drift'); +const paths = await import('../../src/lib/paths.mjs'); +const status = await import('../../src/commands/status.mjs'); +const { loadKitConfig } = await import('../../src/lib/config.mjs'); +const { applyAqeRouter, aqeRouterFile, managedEnv, settingsTarget } = await import('../../src/lib/providers.mjs'); +const { seedActivityRoutes } = await import('../../src/lib/routing.mjs'); +assertSandboxed(paths, HOME); + +const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +// aqe ≥ 3.13.1 so the agentOverrides projection is live (not version-gated out). +paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' })); + +const dualHostCfg = ({ routes = {}, aqeFallback = [] } = {}) => offlineKitConfig({ + integrations: { hosts: { claude: true, codex: true } }, + routing: { version: 1, primaryHost: 'claude', routes }, + providers: { aqeProvider: null, aqeFallback }, +}); + +function seedHome(cfg) { + rmrf(paths.claudeDir(), paths.codexDir(), paths.configDir()); + fs.mkdirSync(paths.claudeDir(), { recursive: true }); + fs.writeFileSync(paths.claudeMdPath(), '# machine notes\n'); + writeKitConfig(HOME, cfg); +} + +/** Park the exact managed env at the scope status will read, so the providers + * row isolates the router-file check (env drift is a separate axis). */ +function neutralizeEnvDrift(cwd) { + const { file } = settingsTarget(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ env: managedEnv(loadKitConfig()) }, null, 2)); +} + +const collect = (cwd) => status.collect({ pkgRoot: PKG_ROOT, cwd }); +const rowsFor = (rows, subsystem) => rows.filter((r) => r.subsystem === subsystem); +const routingRow = (rows) => rowsFor(rows, 'routing').find((r) => r.message.includes('agent overrides')); + +test('a freshly synced PARTIAL policy reports no routing drift (writer projection is the contract)', async () => { + const project = sandboxProject('ak-drift-partial'); + seedHome(dualHostCfg({ routes: { + review: { host: 'claude', model: 'claude-sonnet-5', provenance: 'user' }, + } })); + applyAqeRouter(loadKitConfig(), project); // exactly what `ak sync` runs + + const row = routingRow(await collect(project)); + assert.ok(row, 'routing row present'); + assert.equal(row.level, 'ok', `freshly synced project must not drift: ${row.message}`); +}); + +test('a user pin on a RETIRED model reports no routing drift after sync honors the pin', async () => { + const project = sandboxProject('ak-drift-retired'); + seedHome(dualHostCfg({ routes: { + 'security-scan': { host: 'codex', model: 'gpt-5.4', provenance: 'user' }, + } })); + applyAqeRouter(loadKitConfig(), project); + + const row = routingRow(await collect(project)); + assert.equal(row.level, 'ok', `sync keeps the pin; status must not demand the substitute: ${row.message}`); +}); + +test('foreign agentOverrides entries and key order are the writer\'s merge domain, not drift', async () => { + const project = sandboxProject('ak-drift-foreign'); + seedHome(dualHostCfg({ routes: seedActivityRoutes() })); + // Pre-existing file: a managed key first (so merge order differs from a fresh + // projection) plus a hand-added foreign agent applyAqeRouter must preserve. + fs.mkdirSync(path.dirname(aqeRouterFile(project)), { recursive: true }); + fs.writeFileSync(aqeRouterFile(project), JSON.stringify({ + _managedBy: 'agentic-kit', + agentOverrides: { + 'qe-code-reviewer': { provider: 'claude-code', model: 'claude-sonnet-5' }, + 'qe-custom-agent': { provider: 'ollama' }, + }, + })); + applyAqeRouter(loadKitConfig(), project); + + const row = routingRow(await collect(project)); + assert.equal(row.level, 'ok', `preserved foreign entry / merge order is not drift: ${row.message}`); +}); + +test('outside a git project, status never reports router drift sync refuses to manage', async () => { + const nowhere = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ak-drift-noproj-'))); + seedHome(dualHostCfg({ + routes: seedActivityRoutes(), + aqeFallback: [{ provider: 'claude-code', models: ['claude-opus-4-8'] }], + })); + neutralizeEnvDrift(nowhere); + const res = applyAqeRouter(loadKitConfig(), nowhere); + assert.match(res.detail, /not a project/, 'precondition: sync declines to manage here'); + + const rows = await collect(nowhere); + const drifted = rowsFor(rows, 'providers').filter((r) => r.level === 'warn' && /drifted/.test(r.message)); + assert.deepEqual(drifted.map((r) => r.message), [], + 'providers row must not claim drift the sync it recommends cannot repair'); + const row = routingRow(rows); + assert.ok(row, 'routing row still present (the policy exists)'); + assert.equal(row.level, 'info', `unmanaged location is info, not an unfixable warn: ${row.message}`); + assert.match(row.message, /not in a project/); +}); + +test('from a SUBDIRECTORY of a project, the chain check reads the repo root, not cwd', async () => { + const project = sandboxProject('ak-drift-subdir'); + const subdir = path.join(project, 'src', 'deep'); + fs.mkdirSync(subdir, { recursive: true }); + seedHome(dualHostCfg({ + routes: seedActivityRoutes(), + aqeFallback: [{ provider: 'claude-code', models: ['claude-opus-4-8'] }], + })); + neutralizeEnvDrift(subdir); + applyAqeRouter(loadKitConfig(), project); // sync anchors at the repo root + + const rows = await collect(subdir); + const drifted = rowsFor(rows, 'providers').filter((r) => r.level === 'warn' && /drifted/.test(r.message)); + assert.deepEqual(drifted.map((r) => r.message), [], 'root-anchored file must satisfy a subdir status'); + assert.equal(routingRow(rows).level, 'ok'); +}); + +test('REAL drift is still caught: a hand-edited managed override warns with a sync fix', async () => { + const project = sandboxProject('ak-drift-real'); + seedHome(dualHostCfg({ routes: seedActivityRoutes() })); + applyAqeRouter(loadKitConfig(), project); + const disk = JSON.parse(fs.readFileSync(aqeRouterFile(project), 'utf8')); + disk.agentOverrides['qe-code-reviewer'] = { provider: 'claude-code', model: 'claude-opus-4-8' }; + fs.writeFileSync(aqeRouterFile(project), JSON.stringify(disk)); + + const row = routingRow(await collect(project)); + assert.equal(row.level, 'warn', 'a genuinely diverged managed entry is drift'); + assert.match(row.message, /out of sync/); + assert.equal(row.fix, 'sync re-applies agentOverrides'); +}); + +test('REAL drift is still caught: a configured policy with no file yet warns', async () => { + const project = sandboxProject('ak-drift-nofile'); + seedHome(dualHostCfg({ routes: seedActivityRoutes() })); + // no applyAqeRouter — the file a first sync would create is absent + + const row = routingRow(await collect(project)); + assert.equal(row.level, 'warn', 'missing file in a managed project is drift a sync will fix'); + assert.equal(row.fix, 'sync re-applies agentOverrides'); +});