karajan-code 4.13.0 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  ---
24
24
 
25
- Your AI agent (Claude Code, Codex, Gemini CLI, Cursor…) writes the code. **Karajan governs how it happens**: it installs a method your agent follows on every task, and enforces it with git gates that make a false green structurally impossible.
25
+ Your AI agent (Claude Code, Codex, Copilot, Antigravity, Cursor…) writes the code. **Karajan governs how it happens**: it installs a method your agent follows on every task, and enforces it with git gates that make a false green structurally impossible.
26
26
 
27
27
  - **RAG before assuming** — `kj rag query` answers what your codebase does; no agent guesses. The install wires it as a native MCP tool (`kj_rag_query`) so querying the index is the agent's cheapest path. Works out of the box: local Ollama, or the built-in ONNX embedder when nothing can be installed; cloud embedders require an explicit sensitivity declaration and PII-redact every chunk. A distilled engineering canon rides along: `kj rag query --library` serves pattern cards (when it applies, when it does NOT, the canonical citation) so plans name a greenfield alternative instead of following the legacy line by inertia.
28
28
  - **Card first, on YOUR board** — every piece of work is tracked before it starts: kj's HU Board (`kj hu add|move|list`), the Planning Game, or the board the project already uses (Linear, Trello, Jira, GitHub Issues) via your agent's own MCP/tools. Declared, verified at install, never optional — Karajan does not run without a board. ADRs live in git (`kj adr add|list`).
@@ -34,6 +34,7 @@ Your AI agent (Claude Code, Codex, Gemini CLI, Cursor…) writes the code. **Kar
34
34
  - **Nothing personal ships** — every outbound boundary audits before it leaves the machine: the pre-commit rejects a staged diff carrying your denylisted personal data, hardcoded platform tokens (`ghp_`, `sk-`, `AKIA`…) block outright, `verify-pack`-style tarball scans guard the publish, and `kj privacy scan <dir>` audits any build output. Your denylist lives in `~/.karajan/privacy.yml` — the install asks and writes it for you.
35
35
  - **Installing IS activating** — `kj env install` performs the enforcement itself (git hooks, verdict gate, tool gate) instead of trusting the agent to run setup steps, and ends by printing the method into the very conversation that installed it. A commit outside the method is rejected, not narrated.
36
36
  - **The turn cannot end red — the Sentinel** — a deterministic supervisor (zero LLM) wired into the harness's synchronous hooks records the method state of the session as tools run, and a Stop hook blocks the agent from ending its turn while method violations are open. The program rules, the agent thinks. See [guarantee levels](#guarantee-levels-governed-vs-supervised).
37
+ - **The review panel never runs dry** — nine built-in agents (Claude Code, Codex, GitHub Copilot, Antigravity `agy` — the gemini successor —, Kimi Code, Qwen, OpenCode, Aider, Gemini legacy), and when the configured reviewer exhausts its quota, `kj review` switches to an authenticated candidate with a LOUD notice — or hands you the menu of candidates with their tier (free / subscription / local) and the exact login command. Never a silent failure, never the brain reviewing itself.
37
38
 
38
39
  This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
39
40
 
package/docs/README.es.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ---
16
16
 
17
- Tu agente de IA (Claude Code, Codex, Gemini CLI, Cursor…) escribe el código. **Karajan gobierna cómo ocurre**: instala un método que tu agente sigue en cada tarea y lo hace cumplir con gates de git que hacen el falso verde estructuralmente imposible.
17
+ Tu agente de IA (Claude Code, Codex, Copilot, Antigravity, Cursor…) escribe el código. **Karajan gobierna cómo ocurre**: instala un método que tu agente sigue en cada tarea y lo hace cumplir con gates de git que hacen el falso verde estructuralmente imposible.
18
18
 
19
19
  - **RAG antes de suponer** — `kj rag query` responde qué hace tu código; ningún agente adivina. La instalación lo cablea como herramienta MCP nativa (`kj_rag_query`), de modo que consultar el índice sea el camino más barato del agente. Funciona de serie: Ollama local, o el embedder ONNX integrado cuando no se puede instalar nada; los embedders cloud exigen declarar la sensibilidad y redactan PII de cada chunk. Y viaja con un canon de ingeniería destilado: `kj rag query --library` sirve fichas de patrón (cuándo aplica, cuándo NO, la cita canónica) para que los planes nombren una alternativa greenfield en vez de seguir la línea del legacy por inercia.
20
20
  - **Card primero, en TU board** — todo trabajo se registra antes de empezar: el HU Board de kj (`kj hu add|move|list`), el Planning Game, o el board que el proyecto ya use (Linear, Trello, Jira, GitHub Issues) vía los MCP/tools de tu agente. Declarado, verificado en la instalación, jamás opcional — Karajan no funciona sin board. Los ADRs viven en git (`kj adr add|list`).
@@ -26,6 +26,7 @@ Tu agente de IA (Claude Code, Codex, Gemini CLI, Cursor…) escribe el código.
26
26
  - **Nada personal se publica** — cada boundary de salida se audita antes de dejar la máquina: el pre-commit rechaza un diff con tus datos vetados, los tokens de plataforma hardcodeados (`ghp_`, `sk-`, `AKIA`…) bloquean directamente, el scan del tarball guarda el publish, y `kj privacy scan <dir>` audita cualquier build. Tu denylist vive en `~/.karajan/privacy.yml` — la instalación pregunta y la escribe por ti.
27
27
  - **Instalar ES activar** — `kj env install` ejecuta él mismo el enforcement (hooks de git, gate de veredicto, tool gate) en vez de confiar en que el agente corra pasos de setup, y termina imprimiendo el método en la propia conversación que instaló. Un commit fuera del método se rechaza, no se narra.
28
28
  - **El turno no puede terminar en rojo — el Sentinel** — un supervisor determinista (cero LLM) cableado a los hooks síncronos del harness registra el estado del método de la sesión según corren las herramientas, y un hook Stop bloquea que el agente termine su turno mientras haya violaciones abiertas. El programa manda, el agente piensa. Ver [niveles de garantía](#niveles-de-garantía-gobernado-vs-supervisado).
29
+ - **El panel de revisión nunca se seca** — nueve agentes integrados (Claude Code, Codex, GitHub Copilot, Antigravity `agy` — el sucesor de gemini —, Kimi Code, Qwen, OpenCode, Aider, Gemini legacy), y cuando al reviewer configurado se le agota la cuota, `kj review` cambia a un candidato autenticado AVISANDO en alto — o te entrega el menú de candidatos con su tier (gratis / suscripción / local) y el comando de login exacto. Jamás un fallo mudo, jamás el brain revisándose a sí mismo.
29
30
 
30
31
  Este repo corre bajo su propio entorno: cada commit de karajan-code lleva un veredicto de IA cruzada.
31
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.13.0",
3
+ "version": "4.14.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -76,8 +76,8 @@
76
76
  "test": "vitest run",
77
77
  "test:watch": "vitest",
78
78
  "test:coverage": "vitest run --coverage",
79
- "lint": "eslint src/",
80
- "lint:fix": "eslint src/ --fix",
79
+ "lint": "eslint src/ packages/",
80
+ "lint:fix": "eslint src/ packages/ --fix",
81
81
  "lint:syntax": "((command -v rg >/dev/null 2>&1 && rg --files src tests | rg '\\.js$') || find src tests -type f -name '*.js') | while IFS= read -r f; do node --check \"$f\"; done",
82
82
  "format:check": "prettier --check .",
83
83
  "format:fix": "prettier --write .",
@@ -113,7 +113,9 @@ async function cmdPurge(root, id, out, err) {
113
113
  async function cmdEmpty(root, flags, out) {
114
114
  await ensureRoot(root);
115
115
  const m = await loadManifest(root);
116
- let dropped = [];
116
+ // Assigned in every branch below (the trailing else covers the default),
117
+ // so an initializer would never be read (no-useless-assignment).
118
+ let dropped;
117
119
  if (flags["older-than-days"]) {
118
120
  const days = Number(flags["older-than-days"]);
119
121
  if (!Number.isFinite(days) || days < 0) throw new Error("--older-than-days must be >= 0");
@@ -117,7 +117,7 @@ function humaniseProjectName(id) {
117
117
  if (!id || typeof id !== 'string') return id || '';
118
118
  const tail = id.split(/[/_]/).filter(Boolean).pop() || id;
119
119
  const words = tail
120
- .split(/[\s\-]+/)
120
+ .split(/[\s-]+/)
121
121
  .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
122
122
  .filter((w) => w.length > 0);
123
123
  if (words.length === 0) return id;
@@ -23,7 +23,6 @@
23
23
 
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
- import { homedir } from 'node:os';
27
26
  import {
28
27
  getDb,
29
28
  getKjHome,
@@ -18,7 +18,6 @@
18
18
 
19
19
  import { mkdirSync, openSync } from "node:fs";
20
20
  import { join, dirname } from "node:path";
21
- import { homedir } from "node:os";
22
21
  import { spawn } from "node:child_process";
23
22
  import { fileURLToPath } from "node:url";
24
23
  import { randomUUID } from "node:crypto";
@@ -30,7 +30,6 @@
30
30
 
31
31
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync } from 'node:fs';
32
32
  import { join, dirname } from 'node:path';
33
- import { tmpdir } from 'node:os';
34
33
  import yaml from 'js-yaml';
35
34
  import { getKjHome } from './db.js';
36
35
 
@@ -448,7 +447,7 @@ export function readConfig({ scope = 'global' } = {}) {
448
447
  parsed = yaml.load(readFileSync(p, 'utf8'), { json: true }) || {};
449
448
  exists = true;
450
449
  } catch (err) {
451
- throw new Error(`No se pudo parsear ${p}: ${err.message}`);
450
+ throw new Error(`No se pudo parsear ${p}: ${err.message}`, { cause: err });
452
451
  }
453
452
  }
454
453
  const fields = EDITABLE_FIELDS.map((f) => {
@@ -150,8 +150,6 @@ export function cleanupEphemeralProjects({
150
150
  const candidates = findEphemeralProjects(allProjects, { ...opts, now: now() });
151
151
  if (candidates.length === 0) return [];
152
152
 
153
- const countStories = db.prepare("SELECT COUNT(*) AS n FROM stories WHERE project_id = ?");
154
- const countSessions = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE project_id = ?");
155
153
  const delStories = db.prepare("DELETE FROM stories WHERE project_id = ?");
156
154
  const delSessions = db.prepare("DELETE FROM sessions WHERE project_id = ?");
157
155
  const delProject = db.prepare("DELETE FROM projects WHERE id = ?");
@@ -16,7 +16,6 @@
16
16
  */
17
17
  import { readFileSync, existsSync, readdirSync, mkdirSync, openSync } from 'node:fs';
18
18
  import { join, dirname } from 'node:path';
19
- import { homedir } from 'node:os';
20
19
  import { writeJsonAtomicSync } from 'karajan-core/atomic-write';
21
20
  import { spawn } from 'node:child_process';
22
21
  import { trackRun, untrack } from './run-tracker.js';
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { execSync } from 'node:child_process';
31
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
31
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
32
32
  import { join } from 'node:path';
33
33
  import { getHuBoardPlansDirs } from './db.js';
34
34
 
@@ -21,7 +21,6 @@ export async function readPlanCached(p) {
21
21
  planFileCache.set(p, { mtimeMs, plan });
22
22
  return plan;
23
23
  }
24
- import os from 'node:os';
25
24
  import path from 'node:path';
26
25
  import { spawn as spawnChild } from 'node:child_process';
27
26
  import {
@@ -36,7 +35,6 @@ import {
36
35
  getKjHome,
37
36
  getHuBoardRunsDir,
38
37
  getHuBoardPlansDir,
39
- getHuBoardLegacyPlansDir,
40
38
  getHuBoardPlansDirs,
41
39
  getStoryRow,
42
40
  listPlanIdsForProject,
@@ -93,19 +91,6 @@ function huStoriesDir() {
93
91
  return path.join(getKjHome(), 'hu-stories');
94
92
  }
95
93
 
96
- /**
97
- * Best-effort removal of the hu-stories/<id>/ directory.
98
- */
99
- async function removeBatchDir(batchId) {
100
- try {
101
- const dir = path.join(huStoriesDir(), batchId);
102
- await fsp.rm(dir, { recursive: true, force: true });
103
- return true;
104
- } catch {
105
- return false;
106
- }
107
- }
108
-
109
94
  /**
110
95
  * GET /api/dashboard - Global dashboard statistics.
111
96
  */
@@ -4,7 +4,6 @@ import rateLimit from 'express-rate-limit';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { writeFileSync, rmSync, mkdirSync, realpathSync } from 'node:fs';
7
- import { homedir } from 'node:os';
8
7
  import { initDb, closeDb } from './db.js';
9
8
  import { fullScan, startWatcher } from './sync.js';
10
9
  import apiRoutes from './routes/api.js';
@@ -1,5 +1,5 @@
1
1
  import { watch } from 'chokidar';
2
- import { readFileSync, readdirSync, existsSync, statSync, rmSync } from 'node:fs';
2
+ import { readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
3
3
  import { dirname, join, basename } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import {
@@ -12,7 +12,6 @@
12
12
 
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from "node:fs";
15
- import { homedir } from "node:os";
16
15
  import { dirname, join } from "node:path";
17
16
 
18
17
  const TOKEN_BYTES = 32;
@@ -45,7 +45,6 @@
45
45
 
46
46
  import fs from "node:fs";
47
47
  import path from "node:path";
48
- import os from "node:os";
49
48
 
50
49
  const DEFAULT_CODING_HOURS = 6;
51
50
  const DEFAULT_PAUSED_HOURS = 24;
@@ -0,0 +1,61 @@
1
+ import { BaseAgent } from "./base-agent.js";
2
+ import { resolveBin } from "./resolve-bin.js";
3
+
4
+ /**
5
+ * Antigravity CLI (`agy`) — KJC-TSK-0729 fase 2b: the ninth built-in agent.
6
+ * Google's official successor to the retired gemini CLI (dead 2026-06-18),
7
+ * covered by the user's Google AI Pro/Ultra subscription — the natural
8
+ * third AI for solomon arbitration, back at last.
9
+ *
10
+ * Verified live against agy 1.1.10:
11
+ * - `-p <prompt>` runs print mode; `--output-format json` emits ONE JSON
12
+ * object: {status: "SUCCESS", response, usage}. Non-SUCCESS status can
13
+ * arrive with exit 0, so ok checks BOTH.
14
+ * - `--disable-slash-commands` keeps prompt content (diffs can contain
15
+ * "/lines") from expanding as slash commands in print mode.
16
+ * - Permission prompts are denied in print mode unless
17
+ * `--dangerously-skip-permissions` (coder mode only).
18
+ * - Auth lives under ~/.gemini/antigravity-cli (device/browser login).
19
+ */
20
+ export class AgyAgent extends BaseAgent {
21
+ async runTask(task) {
22
+ return this._exec(task, this.getRoleModel(task.role || "coder"), ["--dangerously-skip-permissions"]);
23
+ }
24
+
25
+ async reviewTask(task) {
26
+ return this._exec(
27
+ { ...task, prompt: `Do NOT use any tools. Answer directly from this prompt only.\n\n${task.prompt}` },
28
+ this.getRoleModel(task.role || "reviewer"),
29
+ [],
30
+ );
31
+ }
32
+
33
+ async _exec(task, model, extraArgs) {
34
+ const args = ["-p", task.prompt, "--output-format", "json", "--disable-slash-commands", ...extraArgs];
35
+ if (model) args.push("--model", model);
36
+ const res = await this.runCommand(resolveBin("agy"), args, {
37
+ onOutput: task.onOutput,
38
+ silenceTimeoutMs: task.silenceTimeoutMs,
39
+ timeout: task.timeoutMs,
40
+ });
41
+ const parsed = extractAgyOutput(res.stdout);
42
+ const ok = res.exitCode === 0 && parsed?.status === "SUCCESS";
43
+ return {
44
+ ok,
45
+ output: parsed?.response ?? res.stdout,
46
+ error: ok ? res.stderr : parsed?.error || res.stderr || `agy status: ${parsed?.status || "unparseable"}`,
47
+ exitCode: res.exitCode,
48
+ };
49
+ }
50
+ }
51
+
52
+ /** Parse agy's single-object JSON print output ({status, response}), null if not JSON. */
53
+ export function extractAgyOutput(stdout) {
54
+ if (!stdout) return null;
55
+ try {
56
+ const obj = JSON.parse(stdout.trim());
57
+ return typeof obj === "object" && obj !== null ? obj : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
@@ -5,6 +5,8 @@ import { AiderAgent } from "./aider-agent.js";
5
5
  import { OpenCodeAgent } from "./opencode-agent.js";
6
6
  import { QwenAgent } from "./qwen-agent.js";
7
7
  import { CopilotAgent } from "./copilot-agent.js";
8
+ import { KimiAgent } from "./kimi-agent.js";
9
+ import { AgyAgent } from "./agy-agent.js";
8
10
 
9
11
  const agentRegistry = new Map();
10
12
 
@@ -53,3 +55,5 @@ registerAgent("aider", AiderAgent, { bin: "aider", installUrl: "https://aider.ch
53
55
  registerAgent("opencode", OpenCodeAgent, { bin: "opencode", installUrl: "https://opencode.ai" });
54
56
  registerAgent("qwen", QwenAgent, { bin: "qwen", installUrl: "https://github.com/QwenLM/qwen-code" });
55
57
  registerAgent("copilot", CopilotAgent, { bin: "copilot", installUrl: "https://docs.github.com/copilot/how-tos/use-copilot-agents/use-copilot-cli" });
58
+ registerAgent("kimi", KimiAgent, { bin: "kimi", installUrl: "https://github.com/MoonshotAI/kimi-code" });
59
+ registerAgent("agy", AgyAgent, { bin: "agy", installUrl: "https://antigravity.google" });
@@ -0,0 +1,44 @@
1
+ import { BaseAgent } from "./base-agent.js";
2
+ import { resolveBin } from "./resolve-bin.js";
3
+
4
+ /**
5
+ * Kimi Code (Moonshot, binary `kimi`) — KJC-TSK-0729 fase 2: the eighth
6
+ * built-in agent and the free-tier reserve of the reviewer/solomon panel.
7
+ *
8
+ * Verified live against kimi-code 0.27.0:
9
+ * - `-p <prompt>` runs one prompt non-interactively and prints the
10
+ * response (text output by default) — the prompt travels as an argument,
11
+ * same E2BIG caveat as claude/copilot (KJC-BUG-0121) for huge diffs.
12
+ * - `-m <model>` picks a model alias; `-y` auto-approves actions.
13
+ * - Auth is the device-code login (`kimi login`), no env keys involved;
14
+ * "No model configured" on a logged machine means the login did not
15
+ * populate the managed provider (re-run `kimi login`).
16
+ */
17
+ export class KimiAgent extends BaseAgent {
18
+ async runTask(task) {
19
+ // Coder mode IS an agentic run: file edits and shell need approval.
20
+ return this._exec(task, this.getRoleModel(task.role || "coder"), ["-y"]);
21
+ }
22
+
23
+ async reviewTask(task) {
24
+ // Review/arbitration answers from the prompt alone — no auto-approval,
25
+ // and an explicit no-tools instruction so a headless run never stalls
26
+ // waiting for a permission prompt nobody can answer.
27
+ return this._exec(
28
+ { ...task, prompt: `Do NOT use any tools. Answer directly from this prompt only.\n\n${task.prompt}` },
29
+ this.getRoleModel(task.role || "reviewer"),
30
+ [],
31
+ );
32
+ }
33
+
34
+ async _exec(task, model, extraArgs) {
35
+ const args = ["-p", task.prompt, ...extraArgs];
36
+ if (model) args.push("-m", model);
37
+ const res = await this.runCommand(resolveBin("kimi"), args, {
38
+ onOutput: task.onOutput,
39
+ silenceTimeoutMs: task.silenceTimeoutMs,
40
+ timeout: task.timeoutMs,
41
+ });
42
+ return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
43
+ }
44
+ }
@@ -50,8 +50,11 @@ export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir
50
50
  * Diff the current surface against the last-seen snapshot and persist the
51
51
  * new state. First run records the baseline silently.
52
52
  */
53
- export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), statePath = path.join(os.homedir(), ".karajan", "ai-surface.json") } = {}) {
54
- const surface = collectAiSurface({ projectDir, home });
53
+ export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), statePath = path.join(os.homedir(), ".karajan", "ai-surface.json"), extraSurface = [] } = {}) {
54
+ // KJC-TSK-0728: extraSurface carries entries the (async) caller collected
55
+ // outside config files — e.g. observed agent CLIs as "grok (cli)" — so
56
+ // they ride the same snapshot and the same "NEW since last check" drift.
57
+ const surface = [...new Set([...collectAiSurface({ projectDir, home }), ...extraSurface])].sort();
55
58
  let state = {};
56
59
  try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* first run or corrupt → baseline */ }
57
60
  const prev = Array.isArray(state[projectDir]) ? state[projectDir] : null;
@@ -12,7 +12,7 @@
12
12
  * the install command and can offer to run it).
13
13
  */
14
14
 
15
- import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
15
+ import { checkBinary, KNOWN_AGENTS, detectObservedAgents } from "../utils/agent-detect.js";
16
16
  import { runCommand } from "../utils/process.js";
17
17
  import { withDocLink } from "../utils/doc-links.js";
18
18
  import { getInstallHint, appliesToStack } from "../utils/install-hints.js";
@@ -38,6 +38,33 @@ function createAgentCheck(agent) {
38
38
  };
39
39
  }
40
40
 
41
+ /**
42
+ * KJC-TSK-0728 — ONE aggregate line for the observation census (agent CLIs
43
+ * kj sees but does not drive). Only FOUND CLIs are listed; the missing ones
44
+ * make no noise, and the check never fails. `detector` is injectable for
45
+ * tests.
46
+ */
47
+ export function createObservedAgentsCheck({ detector = detectObservedAgents } = {}) {
48
+ return {
49
+ name: "agents:observed",
50
+ label: "Agent CLIs observed (not driven)",
51
+ strategy: STRATEGY.MANUAL,
52
+ async detect() {
53
+ let found = [];
54
+ try {
55
+ found = (await detector()).filter((a) => a.available);
56
+ } catch { /* census is best-effort */ }
57
+ return {
58
+ ok: true,
59
+ severity: "info",
60
+ detail: found.length
61
+ ? found.map((a) => `${a.name} (${(a.version || "").split(" ").at(-1) || "?"})`).join(", ")
62
+ : "none detected",
63
+ };
64
+ },
65
+ };
66
+ }
67
+
41
68
  /**
42
69
  * Check the presence of a core binary (node, npm, git).
43
70
  */
@@ -165,6 +192,7 @@ export function getBinaryChecks() {
165
192
  for (const agent of KNOWN_AGENTS) {
166
193
  checks.push(createAgentCheck(agent));
167
194
  }
195
+ checks.push(createObservedAgentsCheck());
168
196
  for (const bin of ["node", "npm", "git"]) {
169
197
  checks.push(createCoreBinaryCheck(bin));
170
198
  }
@@ -7,6 +7,7 @@
7
7
  import { checkHarden } from "../harden/check.js";
8
8
  import { collectMethodStats, formatMethodStats } from "../checks/method.js";
9
9
  import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
10
+ import { detectObservedAgents } from "../utils/agent-detect.js";
10
11
 
11
12
  export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
12
13
  const result = await checkHarden({ projectDir, profile });
@@ -14,8 +15,15 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
14
15
  // rides along in check output but never affects the exit code.
15
16
  const method = await collectMethodStats({ projectDir }).catch(() => null);
16
17
  // KJC-TSK-0694: same deal for the MCP inventory — a nudge, never a gate.
18
+ // KJC-TSK-0728: observed agent CLIs ride the same snapshot as "(cli)"
19
+ // entries, so a newly-appeared agent binary trips the same drift question.
17
20
  let aiSurface = null;
18
- try { aiSurface = checkAiSurface({ projectDir }); } catch { /* inventory is best-effort */ }
21
+ try {
22
+ const clis = (await detectObservedAgents().catch(() => []))
23
+ .filter((a) => a.available)
24
+ .map((a) => `${a.name} (cli)`);
25
+ aiSurface = checkAiSurface({ projectDir, extraSurface: clis });
26
+ } catch { /* inventory is best-effort */ }
19
27
 
20
28
  if (json) {
21
29
  logger.info?.(JSON.stringify({ ...result, method, aiSurface }));
@@ -81,7 +81,14 @@ function applyRoleOverrides(out, flags) {
81
81
  // boolean means the flag belongs to the command itself (`kj audit
82
82
  // --security`, KJC-TSK-0695) and must not become a provider.
83
83
  for (const [flag, role] of ROLE_PROVIDER_FLAGS) {
84
- if (typeof flags[flag] === "string" && flags[flag]) out.roles[role].provider = flags[flag];
84
+ if (typeof flags[flag] === "string" && flags[flag]) {
85
+ // A model pin belongs to the provider it was written for — switching
86
+ // provider by flag drops it (KJC-TSK-0729: codex's pinned mini reached
87
+ // agy, which answered with its model list instead of a verdict). An
88
+ // explicit --<role>-model flag re-pins below, after this loop.
89
+ if (out.roles[role].provider && out.roles[role].provider !== flags[flag]) out.roles[role].model = null;
90
+ out.roles[role].provider = flags[flag];
91
+ }
85
92
  }
86
93
  // coder/reviewer also update top-level aliases
87
94
  if (flags.coder) out.coder = flags.coder;
@@ -16,6 +16,7 @@ import { parseMaybeJsonString } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
17
  import { saveVerdict } from "./verdict-store.js";
18
18
  import { detectWorkspace } from "./workspace.js";
19
+ import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
19
20
 
20
21
  // Cross-AI preference when the configured reviewer IS the host.
21
22
  const CROSS_ORDER = ["codex", "claude", "gemini", "opencode", "aider"];
@@ -44,6 +45,7 @@ export async function runOneShotReview({
44
45
  hostAgent = detectHostAgent(),
45
46
  createAgentFn = createAgent,
46
47
  detectAgents = detectAvailableAgents,
48
+ candidateStatusFn = candidateStatus,
47
49
  }) {
48
50
  if (!diff || !diff.trim()) {
49
51
  throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
@@ -57,26 +59,58 @@ export async function runOneShotReview({
57
59
  }
58
60
 
59
61
  const { rules } = await resolveReviewProfile({ mode: "standard", projectDir });
60
- const prompt = await buildReviewerPrompt({
61
- task: task || "Review the following diff for correctness, security and maintainability.",
62
- diff, reviewRules: rules, mode: "standard", provider: reviewer, projectDir,
63
- });
62
+ const attempt = async (who, cfg = config) => {
63
+ const prompt = await buildReviewerPrompt({
64
+ task: task || "Review the following diff for correctness, security and maintainability.",
65
+ diff, reviewRules: rules, mode: "standard", provider: who, projectDir,
66
+ });
67
+ logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)`);
68
+ return createAgentFn(who, cfg, logger).reviewTask({ prompt, role: "reviewer" });
69
+ };
70
+
71
+ let activeReviewer = reviewer;
72
+ let result = await attempt(activeReviewer);
73
+
74
+ // KJC-TSK-0730 — quota failover: exhausted quota is not a review failure,
75
+ // it is a provider outage. One retry with an authenticated candidate
76
+ // (loud notice, never silent), or an actionable menu. Never the host:
77
+ // the brain does not review itself.
78
+ if (!result?.ok && isQuotaExhausted(result?.error) && (config?.reviewer_options?.auto_fallback ?? true)) {
79
+ const statuses = await candidateStatusFn();
80
+ const fallback = pickQuotaFallback(statuses, { exclude: [activeReviewer, hostAgent].filter(Boolean) });
81
+ if (fallback) {
82
+ logger?.warn?.(
83
+ `⚠ reviewer ${activeReviewer} sin cuota → usando ${fallback} SOLO en esta invocación. ` +
84
+ `Para fijarlo: roles.reviewer.provider: ${fallback} (avisando, nunca en silencio — KJC-TSK-0730).`
85
+ );
86
+ activeReviewer = fallback;
87
+ // The role's model pin belongs to the exhausted provider (a codex
88
+ // model name means nothing to copilot) — the candidate runs on its
89
+ // own default model.
90
+ result = await attempt(activeReviewer, {
91
+ ...config,
92
+ roles: { ...config?.roles, reviewer: { ...config?.roles?.reviewer, model: null } },
93
+ });
94
+ } else {
95
+ throw new Error(
96
+ `reviewer ${activeReviewer} sin cuota y ningún candidato autenticado con adaptador.\n` +
97
+ formatCandidateMenu(statuses, { exclude: [hostAgent].filter(Boolean) })
98
+ );
99
+ }
100
+ }
64
101
 
65
- logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${reviewer} (cross-AI)`);
66
- const agent = createAgentFn(reviewer, config, logger);
67
- const result = await agent.reviewTask({ prompt, role: "reviewer" });
68
102
  if (!result?.ok) {
69
- throw new Error(`reviewer ${reviewer} failed: ${result?.error || "no output"}`);
103
+ throw new Error(`reviewer ${activeReviewer} failed: ${result?.error || "no output"}`);
70
104
  }
71
105
 
72
106
  const parsed = parseMaybeJsonString(result.output);
73
107
  if (!parsed || typeof parsed.approved !== "boolean") {
74
- throw new Error(`reviewer ${reviewer} returned no parseable verdict`);
108
+ throw new Error(`reviewer ${activeReviewer} returned no parseable verdict`);
75
109
  }
76
110
 
77
111
  return saveVerdict(projectDir, diff, {
78
112
  verdict: parsed.approved ? "approved" : "rejected",
79
- reviewer,
113
+ reviewer: activeReviewer,
80
114
  host: hostAgent || null,
81
115
  // KJC-TSK-0680: where the review ran — makes any isolation claim auditable.
82
116
  workspace: await detectWorkspace(projectDir),
@@ -0,0 +1,125 @@
1
+ /**
2
+ * reviewer-fallback (KJC-TSK-0730) — running out of reviewer quota must
3
+ * never leave the method without a third party, and must never be silent:
4
+ * either an authenticated candidate takes over WITH a loud notice, or the
5
+ * user gets a menu of candidates with their tier and the exact login
6
+ * command. Born from the real codex weekly-quota outage of 2026-08-06.
7
+ *
8
+ * The registry is declarative. `install` is only present when the command
9
+ * was VERIFIED (never invent package names); auth heuristics are cheap
10
+ * local file checks — informative for the menu, while the actual failover
11
+ * only sticks if the candidate answers.
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { checkBinary } from "../utils/agent-detect.js";
17
+
18
+ export const REVIEWER_CANDIDATES = [
19
+ {
20
+ name: "codex",
21
+ tier: "suscripción ChatGPT (cuota semanal compartida entre modelos)",
22
+ login: "codex → Sign in with ChatGPT",
23
+ install: "npm i -g @openai/codex",
24
+ authPaths: [".codex/auth.json"],
25
+ },
26
+ {
27
+ name: "copilot",
28
+ tier: "gratis (tier free de GitHub Copilot; más cuota con suscripción)",
29
+ login: "copilot → autenticación GitHub",
30
+ install: "npm i -g @github/copilot",
31
+ authPaths: [".copilot/config.json"],
32
+ },
33
+ {
34
+ name: "agy",
35
+ tier: "suscripción Google AI Pro/Ultra (sucesor del gemini CLI, retirado 06-2026)",
36
+ login: "agy → /login (cuenta Google)",
37
+ install: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
38
+ authPaths: [".gemini/antigravity-cli"],
39
+ },
40
+ {
41
+ name: "kimi",
42
+ tier: "gratis (Kimi Code, Moonshot K2.x)",
43
+ login: "kimi login (device-code; si dice 'No model configured', repítelo)",
44
+ install: null,
45
+ authPaths: [".kimi-code/credentials"],
46
+ },
47
+ {
48
+ name: "qwen",
49
+ tier: "requiere plan o API key (el tier gratis hosted se retiró en 04-2026)",
50
+ login: "qwen → OAuth o API key OpenAI-compatible",
51
+ install: "npm i -g @qwen-code/qwen-code",
52
+ authPaths: [".qwen/oauth_creds.json"],
53
+ },
54
+ {
55
+ name: "aider",
56
+ tier: "API keys propias (pago por token: OpenAI/Anthropic/OpenRouter…)",
57
+ login: "exportar OPENAI_API_KEY / ANTHROPIC_API_KEY (o ~/.aider.conf.yml)",
58
+ install: "pipx install aider-chat || pip3 install aider-chat",
59
+ authPaths: [".aider.conf.yml"],
60
+ authEnv: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"],
61
+ },
62
+ {
63
+ name: "opencode",
64
+ tier: "gratis con modelos locales (LiteLLM/Ollama) o API keys propias",
65
+ login: "provider en ~/.config/opencode/opencode.json",
66
+ install: null,
67
+ authPaths: [".config/opencode/opencode.json"],
68
+ },
69
+ ];
70
+
71
+ const QUOTA_RE = /usage limit|quota|rate.?limit|credits? (?:exhausted|left: ?0)|hit your .{0,20}limit/i;
72
+
73
+ /** True when the agent error smells like exhausted quota, not a real failure. */
74
+ export function isQuotaExhausted(text) {
75
+ return typeof text === "string" && QUOTA_RE.test(text);
76
+ }
77
+
78
+ /** installed × authenticated for every candidate, from cheap local checks. */
79
+ export async function candidateStatus({ home = os.homedir(), checkBin = checkBinary, env = process.env } = {}) {
80
+ return Promise.all(
81
+ REVIEWER_CANDIDATES.map(async (c) => {
82
+ let installed = false;
83
+ try {
84
+ installed = (await checkBin(c.name)).ok;
85
+ } catch { /* not installed */ }
86
+ // Session-file heuristics, or env keys for CLIs (aider) that carry
87
+ // no session file. Key present ≠ credit left — it is menu signal;
88
+ // the failover only sticks if the candidate actually answers.
89
+ const authenticated =
90
+ c.authPaths.some((p) => existsSync(path.join(home, p))) ||
91
+ (c.authEnv || []).some((name) => Boolean(env[name]));
92
+ return { ...c, installed, authenticated };
93
+ }),
94
+ );
95
+ }
96
+
97
+ /**
98
+ * First candidate that is installed, authenticated, has a kj adapter and is
99
+ * not excluded (the exhausted reviewer and the host — the brain NEVER
100
+ * reviews itself).
101
+ */
102
+ export function pickQuotaFallback(statuses, { exclude = [] } = {}) {
103
+ const found = statuses.find(
104
+ (s) => s.installed && s.authenticated && !s.adapterPending && !exclude.includes(s.name),
105
+ );
106
+ return found ? found.name : null;
107
+ }
108
+
109
+ /** Human/agent-readable menu: tier, state, and the exact command per candidate. */
110
+ export function formatCandidateMenu(statuses, { exclude = [] } = {}) {
111
+ const lines = statuses
112
+ .filter((s) => !exclude.includes(s.name))
113
+ .map((s) => {
114
+ let state = "no instalado";
115
+ if (s.authenticated) state = "logado";
116
+ else if (s.installed) state = "instalado, SIN login";
117
+ let action = "";
118
+ if (!s.authenticated) {
119
+ action = s.installed || !s.install ? ` — login: ${s.login}` : ` — instalar: ${s.install}`;
120
+ }
121
+ const pending = s.adapterPending ? " [adaptador kj pendiente: KJC-TSK-0729]" : "";
122
+ return ` - ${s.name} (${s.tier}) · ${state}${action}${pending}`;
123
+ });
124
+ return `Candidatos a reviewer:\n${lines.join("\n")}\nElige uno, haz su login y fija roles.reviewer.provider — o pasa --reviewer <nombre>.`;
125
+ }
@@ -9,9 +9,38 @@ const KNOWN_AGENTS = [
9
9
  { name: "aider", install: getInstallCommand("aider") },
10
10
  { name: "opencode", install: getInstallCommand("opencode") },
11
11
  { name: "qwen", install: getInstallCommand("qwen") },
12
- { name: "copilot", install: getInstallCommand("copilot") }
12
+ { name: "copilot", install: getInstallCommand("copilot") },
13
+ // KJC-TSK-0729: promoted from the observation census once its adapter
14
+ // landed — detecting is not supporting; supporting is.
15
+ { name: "kimi", install: getInstallCommand("kimi") },
16
+ { name: "agy", install: getInstallCommand("agy") }
13
17
  ];
14
18
 
19
+ /**
20
+ * KJC-TSK-0728 — observation census (from the Orca landscape): agent CLIs kj
21
+ * can SEE but does not drive. Detecting is not supporting — these never feed
22
+ * pipeline pickers; they feed the AI-surface inventory and one doctor line.
23
+ * `bin` differs from `name` when the vendor ships an umbrella binary.
24
+ */
25
+ const OBSERVED_AGENTS = [
26
+ { name: "grok", bin: "grok" },
27
+ { name: "cursor-agent", bin: "cursor-agent" },
28
+ { name: "pi", bin: "pi" },
29
+ { name: "kilocode", bin: "kilocode" },
30
+ { name: "vibe", bin: "vibe" },
31
+ { name: "rovodev", bin: "acli" },
32
+ ];
33
+
34
+ /** Probe the observation census in parallel; callers filter on `available`. */
35
+ export async function detectObservedAgents() {
36
+ return Promise.all(
37
+ OBSERVED_AGENTS.map(async (agent) => {
38
+ const check = await checkBinary(agent.bin);
39
+ return { name: agent.name, bin: agent.bin, available: check.ok, version: check.ok ? check.version : null };
40
+ })
41
+ );
42
+ }
43
+
15
44
  export async function checkBinary(name, versionArg = "--version") {
16
45
  const resolved = resolveBin(name);
17
46
  // KJC-BUG-0113: a zombie CLI that hangs on --version (seen with the
@@ -61,4 +90,4 @@ export function isHostAgent(provider) {
61
90
  return host !== null && host === provider;
62
91
  }
63
92
 
64
- export { KNOWN_AGENTS };
93
+ export { KNOWN_AGENTS, OBSERVED_AGENTS };
@@ -56,6 +56,11 @@ const INSTALL_COMMANDS = {
56
56
  linux: "npm install -g @qwen-code/qwen-code",
57
57
  windows: "npm install -g @qwen-code/qwen-code"
58
58
  },
59
+ agy: {
60
+ macos: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
61
+ linux: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
62
+ windows: "irm https://antigravity.google/cli/install.ps1 | iex"
63
+ },
59
64
  copilot: {
60
65
  macos: "npm install -g @github/copilot",
61
66
  linux: "npm install -g @github/copilot",
@@ -32,8 +32,9 @@ const PREFIXES = [
32
32
 
33
33
  // Vars only ONE agent authenticates with — granting them globally would
34
34
  // defeat the point (the coder does not need your GitHub token; copilot does).
35
- const AGENT_EXTRAS = {
35
+ export const AGENT_EXTRAS = {
36
36
  copilot: ["GITHUB_", "GH_"],
37
+ kimi: ["KIMI_", "MOONSHOT_"],
37
38
  };
38
39
 
39
40
  const matches = (pattern, key) =>