karajan-code 3.15.2 → 3.15.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.15.2",
3
+ "version": "3.15.3",
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",
@@ -85,7 +85,8 @@
85
85
  "mcp": "node src/mcp/server.js",
86
86
  "audit:test-diet": "node scripts/audit-test-diet.mjs",
87
87
  "verify-pack": "node scripts/verify-pack.mjs",
88
- "prepublishOnly": "node scripts/verify-pack.mjs"
88
+ "prepublishOnly": "node scripts/verify-pack.mjs",
89
+ "verify-quickstart": "node scripts/quickstart-gate.mjs"
89
90
  },
90
91
  "simple-git-hooks": {
91
92
  "pre-commit": "npx lint-staged"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Quickstart gate (KJC-TSK-0635) — the landing's Quick Start, end to end,
4
+ * against the PACKED tarball, with a REAL coder run.
5
+ *
6
+ * Run manually before a release (`npm run verify-quickstart`). Opt-in
7
+ * because the coder run spends real subscription quota (~$0.5-1.5, 5-8
8
+ * minutes). What it catches that unit tests and verify-pack cannot:
9
+ * the exact first-contact sequence a new user follows — install → init →
10
+ * `kj run` on a no-remote repo → playable artifact (2026-07-19: that
11
+ * sequence surfaced KJC-BUG-0111/0112/0113 in one afternoon).
12
+ *
13
+ * Asserts: run exits 0 · report says approved · index.html exists with
14
+ * game markers · "skipping push/PR" logged · ZERO Solomon escalations.
15
+ */
16
+ import { execFileSync, spawnSync } from "node:child_process";
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+
21
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
22
+ const TASK = "Create a tic-tac-toe game in a single self-contained index.html — vanilla HTML, CSS and JavaScript, no server and no build step. The human plays X, the bot plays O and blocks or takes a winning move when it can. Detect wins and draws, and add a New Game button. Opening index.html in a browser must be enough to play.";
23
+
24
+ function fail(msg, detail) {
25
+ console.error(`\n✗ quickstart-gate: ${msg}`);
26
+ if (detail) console.error(String(detail).slice(-1200));
27
+ process.exitCode = 1;
28
+ throw new Error(msg);
29
+ }
30
+
31
+ let work = null;
32
+ try {
33
+ console.log("quickstart-gate: packing…");
34
+ const packOut = execFileSync("npm", ["pack", "--json", "--silent"], { cwd: repoRoot, encoding: "utf8" });
35
+ const tgz = path.join(repoRoot, JSON.parse(packOut)[0].filename);
36
+
37
+ work = fs.mkdtempSync(path.join(os.tmpdir(), "kj-qs-gate-"));
38
+ const prefix = path.join(work, "npm");
39
+ const project = path.join(work, "project");
40
+ fs.mkdirSync(project, { recursive: true });
41
+
42
+ console.log("quickstart-gate: installing the tarball (isolated prefix)…");
43
+ execFileSync("npm", ["install", "-g", tgz, "--no-audit", "--no-fund", "--silent", "--prefix", prefix], { encoding: "utf8" });
44
+ const kj = path.join(prefix, "bin", "kj");
45
+ fs.rmSync(tgz, { force: true });
46
+
47
+ // Isolated KARAJAN_HOME: faithful to a brand-new user (no global
48
+ // config) and never touches the maintainer's real ~/.karajan.
49
+ const env = { ...process.env, KARAJAN_HOME: path.join(work, "karajan-home") };
50
+ delete env.CLAUDECODE;
51
+ // If the maintainer's machine has a SonarQube server running, preflight
52
+ // (correctly) demands a token the isolated home doesn't have. Forward the
53
+ // maintainer's token so the gate exercises the sonar stage instead of
54
+ // dying in preflight. A machine with no sonar server is unaffected.
55
+ if (!env.KJ_SONAR_TOKEN) {
56
+ try {
57
+ const cfg = fs.readFileSync(path.join(os.homedir(), ".karajan", "kj.config.yml"), "utf8");
58
+ const m = cfg.match(/^\s*token:\s*(sqa_[A-Za-z0-9]+|squ_[A-Za-z0-9]+)\s*$/m);
59
+ if (m) env.KJ_SONAR_TOKEN = m[1];
60
+ } catch { /* no global config — brand-new machine, nothing to forward */ }
61
+ }
62
+
63
+ console.log("quickstart-gate: git init + kj init (unattended)…");
64
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: project });
65
+ const init = spawnSync(kj, ["init", "--no-interactive", "--no-ollama", "--no-rtk", "--no-squeezr", "--no-qmd"], {
66
+ cwd: project, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 300000,
67
+ });
68
+ if (init.status !== 0) fail("kj init failed", init.stderr || init.stdout);
69
+
70
+ console.log("quickstart-gate: kj run (REAL coder — this spends quota, ~5-8 min)…");
71
+ const runRes = spawnSync(kj, ["run", "-y", TASK], {
72
+ cwd: project, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20 * 60 * 1000,
73
+ });
74
+ const runOut = `${runRes.stdout || ""}${runRes.stderr || ""}`;
75
+ fs.writeFileSync(path.join(work, "run.log"), runOut);
76
+
77
+ if (runRes.status !== 0) fail(`kj run exited ${runRes.status} (log: ${work}/run.log)`, runOut);
78
+ if (/escalating to Solomon/i.test(runOut)) fail(`Solomon escalation detected — the happy path must not consult Solomon (log: ${work}/run.log)`);
79
+ // KJC-BUG-0112 regression: a remote-less repo must never attempt (and
80
+ // fail) push/fetch automation. With init's defaults (auto_push: false)
81
+ // the "skipping push/PR" line is legitimately absent, so assert the
82
+ // absence of failure symptoms rather than the presence of the skip line.
83
+ if (/failed to push|fatal: .*origin|couldn't find remote/i.test(runOut)) fail(`push/fetch against a missing remote detected (log: ${work}/run.log)`);
84
+
85
+ const html = path.join(project, "index.html");
86
+ if (!fs.existsSync(html)) fail("index.html was not created");
87
+ const htmlText = fs.readFileSync(html, "utf8");
88
+ if (!/new game/i.test(htmlText)) fail("index.html has no New Game control — not the requested game");
89
+
90
+ const report = spawnSync(kj, ["report"], { cwd: project, env, encoding: "utf8", timeout: 60000 });
91
+ if (!/approved/i.test(`${report.stdout || ""}`)) fail("kj report does not say approved", report.stdout);
92
+
93
+ console.log("\n✓ quickstart-gate: install → init → run → playable index.html → approved. Ship it.");
94
+ } finally {
95
+ if (work && process.exitCode !== 1 && fs.existsSync(work)) fs.rmSync(work, { recursive: true, force: true });
96
+ if (work && process.exitCode === 1) console.error(`quickstart-gate: workdir kept for inspection: ${work}`);
97
+ }
@@ -52,6 +52,7 @@ let tgzPath = null;
52
52
  let tmpDir = null;
53
53
  let gTmp = null;
54
54
  let pnpmTmp = null;
55
+ let qsTmp = null;
55
56
  try {
56
57
  console.log(`verify-pack: packing karajan-code@${expectedVersion}…`);
57
58
  // --json gives us the exact filename without parsing human output.
@@ -146,6 +147,42 @@ try {
146
147
  }
147
148
  console.log(`verify-pack: global install + kj --version → ${gVersion} ✓`);
148
149
 
150
+ // 5.5 Quickstart smoke (KJC-TSK-0635) — the landing's Getting Started
151
+ // sequence against the INSTALLED tarball, no LLM involved. Born from
152
+ // 2026-07-19: following the docs to the letter caught 3 field bugs
153
+ // (silent 2.34.0 install, Solomon loop on no-remote repos, retired
154
+ // gemini CLI) that 6000 unit tests never saw. Aux bootstraps (ollama,
155
+ // rtk, squeezr, qmd, harden) are skipped — this validates kj's own
156
+ // init + config on the canonical no-remote scenario, not the network.
157
+ qsTmp = fs.mkdtempSync(path.join(os.tmpdir(), "kj-verify-qs-"));
158
+ console.log(`verify-pack: quickstart smoke (init on a no-remote repo) in ${qsTmp}…`);
159
+ run("git", ["init", "-q", "-b", "main"], { cwd: qsTmp });
160
+ // KARAJAN_HOME points at an isolated dir: faithful to a brand-new user
161
+ // (no pre-existing global config — `--local` without one is rejected by
162
+ // design, which is exactly what this smoke caught on its first CI run)
163
+ // AND hermetic (never touches the real ~/.karajan).
164
+ const qsEnv = { ...childEnv, KARAJAN_HOME: path.join(qsTmp, "karajan-home") };
165
+ delete qsEnv.CLAUDECODE;
166
+ const initRes = spawnSync(gBin, ["init", "--no-interactive", "--no-ollama", "--no-rtk", "--no-squeezr", "--no-qmd", "--no-harden"], {
167
+ encoding: "utf8", cwd: qsTmp, env: qsEnv, timeout: 180000,
168
+ });
169
+ if (initRes.status !== 0) {
170
+ fail("`kj init --no-interactive` failed on a fresh no-remote repo", (initRes.stderr || initRes.stdout || "").slice(-800));
171
+ }
172
+ const qsCfgLocal = path.join(qsTmp, ".karajan", "kj.config.yml");
173
+ const qsCfgGlobal = path.join(qsTmp, "karajan-home", "kj.config.yml");
174
+ const qsCfg = fs.existsSync(qsCfgLocal) ? qsCfgLocal : qsCfgGlobal;
175
+ if (!fs.existsSync(qsCfg)) fail(`kj init did not write ${qsCfg}`);
176
+ if (!/coder:\s*\S+/.test(fs.readFileSync(qsCfg, "utf8"))) {
177
+ fail("generated kj.config.yml has no coder assignment");
178
+ }
179
+ const reportRes = spawnSync(gBin, ["report"], { encoding: "utf8", cwd: qsTmp, env: qsEnv, timeout: 60000 });
180
+ const reportOut = `${reportRes.stdout || ""}${reportRes.stderr || ""}`;
181
+ if (reportRes.status !== 0 && !/no session|sin sesi|not found/i.test(reportOut)) {
182
+ fail("`kj report` crashed on a project with no sessions", reportOut.slice(-400));
183
+ }
184
+ console.log("verify-pack: quickstart smoke (init + report, no-remote repo) ✓");
185
+
149
186
  // 6. pnpm install smoke (KJC-TSK-0580). pnpm's layout differs from npm's
150
187
  // (a symlinked virtual store), so it can break resolution of the bundled
151
188
  // karajan-core the way npm packaging breakage did before (KJC-BUG-0082/0086).
@@ -195,4 +232,5 @@ try {
195
232
  if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
196
233
  if (gTmp && fs.existsSync(gTmp)) fs.rmSync(gTmp, { recursive: true, force: true });
197
234
  if (pnpmTmp && fs.existsSync(pnpmTmp)) fs.rmSync(pnpmTmp, { recursive: true, force: true });
235
+ if (qsTmp && fs.existsSync(qsTmp)) fs.rmSync(qsTmp, { recursive: true, force: true });
198
236
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { exists } from "../utils/fs.js";
4
4
  import { getSessionRoot } from "../utils/paths.js";
5
5
  import { loadConfig } from "../config.js";
6
+ import { DEFAULTS } from "../config/defaults.js";
6
7
 
7
8
  function parseBudgetFromActivityLog(logText) {
8
9
  if (!logText) {
@@ -131,6 +132,14 @@ async function buildReport(dir, sessionId) {
131
132
 
132
133
  const sonar = summarizeSonar(checkpoints);
133
134
  const budget = parseBudgetFromActivityLog(activityLog);
135
+ // KJC-BUG-0114: activity logs written when max_budget_usd was null carry
136
+ // a phantom "$0.00" ceiling (Number(null) === 0). Resolve the effective
137
+ // ceiling from the session's config snapshot, falling back to the
138
+ // shipped default; an explicit 0 means "no ceiling" and drops the suffix.
139
+ if (budget.limit_usd === 0) {
140
+ const snapBudget = session.config_snapshot?.max_budget_usd;
141
+ budget.limit_usd = snapBudget == null ? DEFAULTS.max_budget_usd : (Number(snapBudget) || null);
142
+ }
134
143
  const commits = summarizeCommits(session, checkpoints);
135
144
 
136
145
  const budgetTrace = Array.isArray(session.budget?.trace) ? session.budget.trace : [];
@@ -12,6 +12,7 @@ import { resolveRole } from "../config.js";
12
12
  import { emitProgress, makeEvent } from "../utils/events.js";
13
13
  import { getTemplatesRoot } from "../utils/templates-root.js";
14
14
  import { BudgetTracker, extractUsageMetrics } from "../utils/budget.js";
15
+ import { DEFAULTS } from "../config/defaults.js";
15
16
  import { computeKjComparison } from "../budget/comparison.js";
16
17
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
17
18
  import { projectSlug } from "../plan/plan-store.js";
@@ -303,8 +304,13 @@ export async function handleDryRun({ task, config, flags, emitter, pipelineFlags
303
304
 
304
305
  export function createBudgetManager({ config, emitter, eventBase, getCompressionStats = null }) {
305
306
  const budgetTracker = new BudgetTracker({ pricing: config?.budget?.pricing });
306
- const budgetLimit = Number(config?.max_budget_usd);
307
- const hasBudgetLimit = Number.isFinite(budgetLimit) && budgetLimit >= 0;
307
+ // KJC-BUG-0114: Number(null) === 0, so a config that reached us with
308
+ // max_budget_usd: null produced a phantom "$X / $0.00" ceiling (and a
309
+ // permanent warn state). null/undefined fall back to the shipped
310
+ // default ceiling; an explicit 0 means "no ceiling".
311
+ const rawBudget = config?.max_budget_usd;
312
+ const budgetLimit = rawBudget == null ? DEFAULTS.max_budget_usd : Number(rawBudget);
313
+ const hasBudgetLimit = Number.isFinite(budgetLimit) && budgetLimit > 0;
308
314
  const warnThresholdPct = Number(config?.budget?.warn_threshold_pct ?? 80);
309
315
  let stageCounter = 0;
310
316
 
@@ -21,9 +21,8 @@ import { prepareGitAutomation } from "../../git/automation.js";
21
21
  import { CoderRole } from "../../roles/coder-role.js";
22
22
  import { PipelineContext } from "../pipeline-context.js";
23
23
  import { detectRtk } from "../../utils/rtk-detect.js";
24
- import { createRtkRunner, RtkSavingsTracker } from "../../utils/rtk-wrapper.js";
25
- import { setRunner as setDiffRunner, setProjectDir as setDiffProjectDir } from "../../review/diff-generator.js";
26
- import { setRunner as setGitRunner } from "../../utils/git.js";
24
+ import { RtkSavingsTracker } from "../../utils/rtk-wrapper.js";
25
+ import { setProjectDir as setDiffProjectDir } from "../../review/diff-generator.js";
27
26
  import {
28
27
  loadProductContext, resolvePipelineFlags, createBudgetManager,
29
28
  initializeSession, autoInit,
@@ -116,19 +115,19 @@ export async function initFlowContext({ task, config, logger, emitter, askQuesti
116
115
  const rtkResult = await detectRtk();
117
116
  if (rtkResult.available) {
118
117
  config = { ...config, rtk: { available: true, version: rtkResult.version } };
119
- const rtkTracker = new RtkSavingsTracker();
120
- const rtkRunner = createRtkRunner(true, rtkTracker);
121
- // TSK-0338: install rtkRunner into the per-run context; module-level
122
- // setters are the back-compat path for runs outside a withRunContext scope.
123
- if (runCtx) runCtx.runner = rtkRunner;
124
- else {
125
- setDiffRunner(rtkRunner);
126
- setGitRunner(rtkRunner);
127
- }
128
- ctx.rtkTracker = rtkTracker;
129
- logger.info(`RTK detected (${rtkResult.version}) — wrapping internal git/diff commands with rtk`);
118
+ // KJC-BUG-0115: rtk MUST NOT wrap the pipeline's internal git/diff
119
+ // commands. `rtk git diff` emits a compressed summary without
120
+ // `diff --git` headers, so every consumer that parses the output
121
+ // (tdd-policy extractChangedFiles, reviewer diffs, status checks)
122
+ // sees an empty change set — in the field this made the TDD gate
123
+ // fail forever with "(2 src, 0 test)" while real tests existed.
124
+ // RTK still saves tokens where it belongs: inside the coder agent's
125
+ // own shell, via its Claude Code hook. Detection is kept so the
126
+ // config advertises availability to agents.
127
+ ctx.rtkTracker = new RtkSavingsTracker();
128
+ logger.info(`RTK detected (${rtkResult.version}) — available to agents (internal git/diff stay unwrapped)`);
130
129
  emitProgress(emitter, makeEvent("rtk:detected", ctx.eventBase, {
131
- message: "RTK detected — internal commands wrapped for token optimization",
130
+ message: "RTK detected — available to agents",
132
131
  detail: { version: rtkResult.version, executorType: "local" }
133
132
  }));
134
133
  }
@@ -253,6 +253,7 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
253
253
  }
254
254
  if (iterResult.action === "retry") { i -= 1; }
255
255
  else {
256
+ await ensureIterationRecorded(ctx, i, logger);
256
257
  // Iteration gate (KJC-TSK-0628): opt-in pause with a report before the
257
258
  // next iteration; free-text answers become directives for the coder.
258
259
  const gate = await handleIterationGate({
@@ -306,6 +307,7 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
306
307
  if (iterResult.action === "return") return iterResult.result;
307
308
  if (iterResult.action === "retry") { i -= 1; }
308
309
  else {
310
+ await ensureIterationRecorded(ctx, i, logger);
309
311
  // Same iteration gate on Solomon-extended iterations (KJC-TSK-0628).
310
312
  const gate = await handleIterationGate({
311
313
  enabled: ctx.config.session?.iteration_gate === true,
@@ -323,9 +325,56 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
323
325
 
324
326
  // Extended iterations also exhausted — final Solomon call
325
327
  const finalResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask, rtkTracker: ctx.rtkTracker, brainCtx: ctx.brainCtx });
326
- return finalResult;
328
+ return finalizeMaxIterationsApproval(ctx, finalResult, { task: loopTask, askQuestion, emitter, logger, i });
327
329
  }
328
330
 
329
- return maxIterResult;
331
+ return finalizeMaxIterationsApproval(ctx, maxIterResult, { task: loopTask, askQuestion, emitter, logger, i });
332
+ }
333
+
334
+ // KJC-BUG-0117: iterations that end before the reviewer (TDD/sonar
335
+ // "continue", guard retries) never reached the recordIteration call at
336
+ // the end of runSingleIteration, so session._journalIterations stayed
337
+ // empty and the journal read "Iterations: 0" after a 5-iteration run.
338
+ export async function ensureIterationRecorded(ctx, i, logger) {
339
+ if (!ctx.journalIterations) return;
340
+ if ((ctx.session._journalIterations?.length || 0) >= i) return;
341
+ try {
342
+ const { recordIteration, extractIterationData } = await import("../../session/journal/iteration-logger.js");
343
+ recordIteration(ctx.session, extractIterationData({
344
+ iteration: i, durationMs: 0, stageResults: ctx.stageResults, session: ctx.session,
345
+ }));
346
+ } catch (err) {
347
+ logger.warn(`Iteration journal record failed (non-blocking): ${err.message}`);
348
+ }
349
+ }
350
+
351
+ // KJC-BUG-0116: an "approved" verdict at max_iterations (brain_approved,
352
+ // brain_solomon_approved, solomon_approved) used to be returned as-is,
353
+ // skipping the entire post-loop (tester/security/audit) AND git
354
+ // automation (push/PR) that every reviewer-approved session gets — the
355
+ // session read "approved" with zero verification and zero push. Route it
356
+ // through the same handleApprovedReview path instead.
357
+ export async function finalizeMaxIterationsApproval(ctx, maxIterResult, { task, askQuestion, emitter, logger, i }) {
358
+ if (maxIterResult?.approved !== true) return maxIterResult;
359
+
360
+ const review = {
361
+ approved: true,
362
+ raw_summary: `Finalized at max_iterations (${maxIterResult.reason || "approved"})`,
363
+ blocking_issues: []
364
+ };
365
+ const fin = await handleApprovedReview({
366
+ config: ctx.config, session: ctx.session, emitter, eventBase: ctx.eventBase,
367
+ coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, i, task,
368
+ stageResults: ctx.stageResults, pipelineFlags: ctx.pipelineFlags, askQuestion, logger,
369
+ gitCtx: ctx.gitCtx, budgetSummary: ctx.budgetSummary, pgCard: ctx.pgCard, pgProject: ctx.pgProject,
370
+ review, rtkTracker: ctx.rtkTracker, brainCtx: ctx.brainCtx
371
+ });
372
+ if (fin.action === "return") return fin.result;
373
+
374
+ // Post-loop demanded another coder pass but iterations are exhausted —
375
+ // report honestly instead of a false green.
376
+ logger.warn("Post-loop stages rejected the work at max_iterations — session NOT approved");
377
+ await markSessionStatus(ctx.session, "failed");
378
+ return { approved: false, sessionId: ctx.session.id, reason: "post_loop_rejected_at_max_iterations" };
330
379
  }
331
380
 
@@ -389,16 +389,19 @@ async function handleTddFailure({ tddEval, config, logger, emitter, eventBase, s
389
389
  return { action: "continue" };
390
390
  }
391
391
 
392
- // Brain: when enabled, skip Solomon — Brain handles via max_iterations
392
+ // Brain: at the sub-loop limit the TDD gate must stop eating iterations.
393
+ // KJC-BUG-0115: returning "continue" here short-circuited runSingleIteration
394
+ // before the reviewer gate — 5/5 iterations ended without any review. The
395
+ // failure is already queued as feedback; "proceed" lets the reviewer run.
393
396
  if (brainCtx?.enabled) {
394
- logger.info("Brain: TDD sub-loop limit reached — Brain will handle via max_iterations (Solomon bypassed)");
397
+ logger.info("Brain: TDD sub-loop limit reached — proceeding to reviewer with TDD failure as pending feedback");
395
398
  emitProgress(emitter, makeEvent("brain:tdd-retry-limit", { ...eventBase, stage: "tdd" }, {
396
- message: `TDD sub-loop limit reached (${session.repeated_issue_count}/${config.session.fail_fast_repeats}) — Brain handling`,
399
+ message: `TDD sub-loop limit reached (${session.repeated_issue_count}/${config.session.fail_fast_repeats}) — proceeding to reviewer`,
397
400
  detail: { subloop: "tdd", retryCount: session.repeated_issue_count, reason: tddEval.reason }
398
401
  }));
399
402
  resetRetryCount(session, "repeated_issue");
400
403
  await saveSession(session);
401
- return { action: "continue" };
404
+ return { action: "proceed" };
402
405
  }
403
406
 
404
407
  emitProgress(
@@ -15,7 +15,13 @@ const CAPABILITY_TIERS = {
15
15
  // Role requirements: what capability level is ideal for each role
16
16
  const ROLE_PREFERENCES = {
17
17
  brain: { minTier: 4, prefer: "claude", description: "Karajan Brain (orchestrator)" },
18
- solomon: { minTier: 3, prefer: "gemini", description: "Solomon (judge/arbiter)" },
18
+ // KJC-BUG-0113: Solomon preferred gemini, but Google retired the Gemini
19
+ // Code Assist CLI for individuals — a binary that answers --version yet
20
+ // dies with IneligibleTierError on every real call. Any machine with the
21
+ // stale CLI on PATH got a judge that could never rule. Claude matches
22
+ // the brain default; diversifyReviewer still picks a different agent
23
+ // when more than one healthy option exists.
24
+ solomon: { minTier: 3, prefer: "claude", description: "Solomon (judge/arbiter)" },
19
25
  coder: { minTier: 2, prefer: "claude", description: "Coder" },
20
26
  reviewer: { minTier: 3, prefer: "codex", description: "Reviewer" },
21
27
  planner: { minTier: 4, prefer: "claude", description: "Planner" },