karajan-code 4.13.0 → 4.15.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.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/docs/README.es.md +2 -1
  3. package/package.json +3 -3
  4. package/packages/ai-trash/src/cli.js +3 -1
  5. package/packages/hu-board/public/utils/formatters.js +1 -1
  6. package/packages/hu-board/src/cleanup-zombies.js +0 -1
  7. package/packages/hu-board/src/command-runner.js +0 -1
  8. package/packages/hu-board/src/config-yaml.js +1 -2
  9. package/packages/hu-board/src/ephemeral-cleaner.js +0 -2
  10. package/packages/hu-board/src/plan-mutations.js +0 -1
  11. package/packages/hu-board/src/preflight.js +1 -1
  12. package/packages/hu-board/src/routes/api.js +0 -15
  13. package/packages/hu-board/src/server.js +0 -1
  14. package/packages/hu-board/src/sync.js +1 -1
  15. package/packages/hu-board/src/token-store.js +0 -1
  16. package/packages/hu-board/src/zombie-reaper.js +0 -1
  17. package/src/agents/agy-agent.js +62 -0
  18. package/src/agents/claude-agent.js +3 -2
  19. package/src/agents/codex-agent.js +3 -1
  20. package/src/agents/index.js +4 -0
  21. package/src/agents/kimi-agent.js +45 -0
  22. package/src/checks/ai-surface.js +5 -2
  23. package/src/checks/binaries.js +29 -1
  24. package/src/cli/advanced-commands.js +1 -1
  25. package/src/cli/register-pipeline.js +26 -4
  26. package/src/commands/check.js +9 -1
  27. package/src/commands/harden.js +1 -1
  28. package/src/commands/tournament.js +85 -0
  29. package/src/config/overrides.js +8 -1
  30. package/src/harden/config-engine.js +27 -0
  31. package/src/harden/config-templates.js +8 -3
  32. package/src/harden/workflow-engine.js +11 -1
  33. package/src/harden/workflow-templates.js +41 -22
  34. package/src/prompts/diff-clip.js +27 -0
  35. package/src/prompts/reviewer.js +8 -2
  36. package/src/prompts/split-signal.js +82 -0
  37. package/src/review/one-shot-review.js +44 -10
  38. package/src/review/reviewer-fallback.js +125 -0
  39. package/src/roles/reviewer-role.js +9 -8
  40. package/src/tournament/crown.js +88 -0
  41. package/src/tournament/judge.js +151 -0
  42. package/src/tournament/run.js +142 -0
  43. package/src/tournament/scoreboard.js +124 -0
  44. package/src/utils/agent-detect.js +31 -2
  45. package/src/utils/os-detect.js +5 -0
  46. package/src/utils/role-env.js +2 -1
@@ -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
+ }
@@ -2,21 +2,16 @@ import { AgentRole } from "./agent-role.js";
2
2
  import { buildRtkInstructions } from "../prompts/rtk-snippet.js";
3
3
  import { extractFirstJson } from "../utils/json-extract.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "../prompts/prompt-layout.js";
5
+ import { clipDiff } from "../prompts/diff-clip.js";
6
+ import { buildSplitSignal } from "../prompts/split-signal.js";
5
7
  import { isMutationReviewEnabled, buildReviewerMutationSignal } from "../mutate/reviewer-signal.js";
6
8
 
7
- const MAX_DIFF_LENGTH = 12000;
8
-
9
9
  const SUBAGENT_PREAMBLE = [
10
10
  "IMPORTANT: You are running as a Karajan sub-agent.",
11
11
  "Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
12
12
  "Do NOT use any MCP tools. Focus only on reviewing the code."
13
13
  ].join(" ");
14
14
 
15
- function truncateDiff(diff) {
16
- if (!diff) return "";
17
- return diff.length > MAX_DIFF_LENGTH ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n\n[TRUNCATED]` : diff;
18
- }
19
-
20
15
  export class ReviewerRole extends AgentRole {
21
16
  constructor(opts) {
22
17
  super({ ...opts, name: "reviewer" });
@@ -44,6 +39,10 @@ export class ReviewerRole extends AgentRole {
44
39
  enabled: isMutationReviewEnabled(),
45
40
  projectDir: this.config?.projectDir,
46
41
  });
42
+ // KJC-BUG-0134: a clip is declared as kj's own, outside the diff body.
43
+ const { body: clippedDiff, note: clipNote } = clipDiff(diff || "");
44
+ // KJC-BUG-0138: moved-lines correlation, computed on the FULL diff.
45
+ const splitSignal = buildSplitSignal(diff || "");
47
46
  const layout = buildPromptLayout([
48
47
  section(SUBAGENT_PREAMBLE, STABLE),
49
48
  section(this.instructions, STABLE),
@@ -56,7 +55,9 @@ export class ReviewerRole extends AgentRole {
56
55
  section(buildRtkInstructions({ rtkAvailable: Boolean(this.config?.rtk?.available) }), STABLE),
57
56
  section(reviewRules ? `Review rules:\n${reviewRules}` : null, STABLE),
58
57
  section(`Task context:\n${task}`, VOLATILE),
59
- section(`Git diff:\n${truncateDiff(diff)}`, VOLATILE),
58
+ section(`Git diff:\n${clippedDiff}`, VOLATILE),
59
+ section(clipNote, VOLATILE),
60
+ section(splitSignal, VOLATILE),
60
61
  section(mutationSignal, VOLATILE),
61
62
  ]);
62
63
  return { prompt: joinLayout(layout), stablePrompt: layout.stable, volatilePrompt: layout.volatile };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * tournament/crown — TOR-C (KJC-TSK-0725, epic KJC-PCS-0073). The last act:
3
+ * the tournament winner enters main THROUGH THE NORMAL DOOR, not around it.
4
+ *
5
+ * In the winner's still-alive lane: stage everything, run the real cross-AI
6
+ * review over the EXACT staged diff (verdict bound to its sha256, stored by
7
+ * the same verdict-store every commit uses), then commit — where the
8
+ * pre-commit gate validates that verdict like any other commit's. A
9
+ * rejected review ABORTS the coronation with the reasons: winning the
10
+ * tournament earns you a candidacy, not a bypass. Losers keep their full
11
+ * dossier under the tournament dir; their lanes are never removed here.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import { runOneShotReview } from "../review/one-shot-review.js";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ const defaultExec = async (cmd, args, { cwd }) => {
22
+ const res = await execFileAsync(cmd, args, { cwd, maxBuffer: 64 * 1024 * 1024 });
23
+ return { exitCode: 0, stdout: res.stdout || "" };
24
+ };
25
+
26
+ async function defaultReview({ lane, task, config, logger }) {
27
+ const { stdout: diff } = await execFileAsync("git", ["diff", "--cached"], { cwd: lane, maxBuffer: 64 * 1024 * 1024 });
28
+ try {
29
+ const record = await runOneShotReview({
30
+ diff,
31
+ task: `Tournament winner review. Original task:\n${task}`,
32
+ config: { ...config, projectDir: lane },
33
+ logger,
34
+ projectDir: lane,
35
+ });
36
+ return { ok: record.verdict === "approved", ...record };
37
+ } catch (err) {
38
+ return { ok: false, verdict: "error", error: err.message };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Crown the judged winner: governed review + commit in its lane.
44
+ * @returns {Promise<{winner: string, branch: string, commit: string, verdict: object}>}
45
+ */
46
+ export async function crownWinner({ tournamentDir, config = {}, logger = console, cardRef = null, deps = {} }) {
47
+ const { execFn = defaultExec, reviewFn = defaultReview } = deps;
48
+
49
+ const judgementPath = path.join(tournamentDir, "judgement.json");
50
+ if (!fs.existsSync(judgementPath)) {
51
+ throw new Error("tournament: no hay judgement.json — corre primero kj tournament --judge <id>");
52
+ }
53
+ const judgement = JSON.parse(fs.readFileSync(judgementPath, "utf8"));
54
+ const summary = JSON.parse(fs.readFileSync(path.join(tournamentDir, "summary.json"), "utf8"));
55
+ const winner = judgement.winner;
56
+ const meta = JSON.parse(fs.readFileSync(path.join(tournamentDir, winner, "meta.json"), "utf8"));
57
+ const lane = meta.lane;
58
+ if (!lane || !fs.existsSync(lane)) {
59
+ throw new Error(`tournament: el carril del ganador (${winner}) ya no existe — corona justo después de juzgar, antes de limpiar carriles`);
60
+ }
61
+
62
+ logger?.info?.(`coronando a ${winner} en ${meta.branch} — por la puerta normal, no por un atajo`);
63
+ await execFn("git", ["add", "-A"], { cwd: lane });
64
+
65
+ const verdict = await reviewFn({ lane, task: summary.task, config, logger });
66
+ if (!verdict?.ok) {
67
+ const issues = (verdict?.issues || []).map((i) => ` - ${i.description}`).join("\n");
68
+ throw new Error(
69
+ `tournament: la review RECHAZÓ al ganador (${winner}) — ganar el torneo da candidatura, no bypass.\n${issues || verdict?.error || ""}`
70
+ );
71
+ }
72
+
73
+ const ref = cardRef ? ` (${cardRef})` : "";
74
+ const msg = `feat(tournament): crown ${winner} — ${String(summary.task).slice(0, 50).toLowerCase()}${ref} [${summary.id}]`;
75
+ await execFn("git", ["commit", "-m", msg], { cwd: lane });
76
+ const { stdout } = await execFn("git", ["rev-parse", "HEAD"], { cwd: lane });
77
+ const commit = stdout.trim();
78
+
79
+ const crown = {
80
+ winner, branch: meta.branch, commit,
81
+ verdict: { verdict: verdict.verdict, reviewer: verdict.reviewer, diffHash: verdict.diffHash },
82
+ crowned_at: new Date().toISOString(),
83
+ };
84
+ fs.writeFileSync(path.join(tournamentDir, "crown.json"), JSON.stringify(crown, null, 2));
85
+ logger?.info?.(`✓ coronado: ${winner} · commit ${commit.slice(0, 9)} en ${meta.branch} — la rama queda lista para PR`);
86
+ logger?.info?.(" los perdedores conservan su expediente en el dir del torneo; limpia carriles con kj worktree done cuando quieras");
87
+ return { winner, branch: meta.branch, commit, verdict: crown.verdict };
88
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * tournament/judge — TOR-C (KJC-TSK-0725, epic KJC-PCS-0073).
3
+ *
4
+ * The scoreboard (TOR-B) ELIMINATES; the judge CHOOSES among survivors —
5
+ * a cross-AI reviewer ranking the finalists with the deterministic table
6
+ * in sight, so the LLM judges what metrics cannot see (design, readability,
7
+ * over-engineering) without overruling what they can. Two hard rules:
8
+ * a participant coder never judges its own tournament (nor does the host),
9
+ * and a WINNER disagreement with the scoreboard — or a declared tie —
10
+ * escalates to solomon carrying BOTH positions. Only first place crowns:
11
+ * lower-order differences are informative and never escalate (the card's
12
+ * "discrepancia fuerte" — over-escalating would make solomon the judge). Diffs travel WHOLE or the limit is
13
+ * DECLARED in the prompt (the [TRUNCATED]-blamed-on-the-file lesson,
14
+ * BUG-0134): the judge must never mistake our clipping for the coder's code.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { createAgent } from "../agents/index.js";
19
+ import { resolveRole } from "../config/role-resolver.js";
20
+ import { detectHostAgent } from "../utils/agent-detect.js";
21
+ import { candidateStatus } from "../review/reviewer-fallback.js";
22
+
23
+ const MAX_DIFF_CHARS = 60_000;
24
+
25
+ /** The configured reviewer unless it competed or hosts; else the first clean candidate. */
26
+ export function pickJudge({ configured, participants = [], host = null, statuses = [] }) {
27
+ const dirty = new Set([...participants, host].filter(Boolean));
28
+ if (configured && !dirty.has(configured)) return configured;
29
+ const clean = statuses.find((s) => s.installed && s.authenticated && !s.adapterPending && !dirty.has(s.name));
30
+ return clean ? clean.name : null;
31
+ }
32
+
33
+ async function defaultJudge({ config, participants }) {
34
+ const configured = resolveRole(config, "reviewer").provider;
35
+ const statuses = await candidateStatus();
36
+ return pickJudge({ configured, participants, host: detectHostAgent(), statuses });
37
+ }
38
+
39
+ async function defaultAgent({ judge, prompt, config, logger }) {
40
+ return createAgent(judge, config, logger).reviewTask({ prompt, role: "reviewer" });
41
+ }
42
+
43
+ function finalistBlock(board, torDir, coder) {
44
+ const row = board.rows.find((r) => r.coder === coder);
45
+ let diff = fs.readFileSync(path.join(torDir, coder, "diff.patch"), "utf8");
46
+ let clipped = false;
47
+ if (diff.length > MAX_DIFF_CHARS) {
48
+ diff = diff.slice(0, MAX_DIFF_CHARS);
49
+ clipped = true;
50
+ }
51
+ return [
52
+ `### Finalista: ${coder}`,
53
+ `Scoreboard: ${JSON.stringify(row)}`,
54
+ clipped
55
+ ? `NOTA DEL SISTEMA: el diff se muestra RECORTADO a ${MAX_DIFF_CHARS} caracteres por límite del prompt — el recorte es NUESTRO, no un defecto del código; júzgalo por lo visible y dilo en tus razones.`
56
+ : "",
57
+ "```diff", diff, "```",
58
+ ].filter(Boolean).join("\n");
59
+ }
60
+
61
+ /**
62
+ * Judge a scored tournament: rank finalists, escalate on tie/disagreement.
63
+ * @returns {Promise<{winner: string, judge: string|null, escalated: boolean, walkover?: boolean}>}
64
+ */
65
+ export async function judgeTournament({ tournamentDir, config = {}, logger = console, deps = {} }) {
66
+ const { judgeFn = defaultJudge, agentFn = defaultAgent, solomonFn = null } = deps;
67
+ const board = JSON.parse(fs.readFileSync(path.join(tournamentDir, "scoreboard.json"), "utf8"));
68
+ const finalists = board.rows.filter((r) => r.finalist).map((r) => r.coder);
69
+ const participants = board.rows.map((r) => r.coder);
70
+
71
+ if (finalists.length === 0) throw new Error("tournament: sin finalistas (todas las suites rojas) — no hay nada que juzgar");
72
+ if (finalists.length === 1) {
73
+ const judgement = { winner: finalists[0], judge: null, escalated: false, walkover: true, board_ranking: board.ranking };
74
+ fs.writeFileSync(path.join(tournamentDir, "judgement.json"), JSON.stringify(judgement, null, 2));
75
+ logger?.info?.(`torneo ${board.id}: ganador por walkover — ${finalists[0]} (único finalista)`);
76
+ return judgement;
77
+ }
78
+
79
+ const judge = await judgeFn({ config, participants });
80
+ if (!judge) {
81
+ throw new Error(
82
+ "tournament: sin juez limpio — todos los agentes disponibles compitieron o son el host. " +
83
+ "Un participante jamás juzga su propio torneo: quita un coder o añade un agente."
84
+ );
85
+ }
86
+
87
+ const prompt = [
88
+ "Eres el JUEZ de un torneo de código: varios coders resolvieron la MISMA tarea de forma independiente.",
89
+ "La fase determinista ya eliminó a los que suspendieron la suite. Tu trabajo: ordenar a los finalistas",
90
+ "por calidad de solución (diseño, legibilidad, ausencia de sobre-ingeniería), con el scoreboard a la vista.",
91
+ `Regla del scoreboard: ${board.rule}`,
92
+ `Ranking del scoreboard: ${board.ranking.join(" > ")}`,
93
+ `TAREA ORIGINAL:\n${board.task}`,
94
+ ...finalists.map((c) => finalistBlock(board, tournamentDir, c)),
95
+ 'Responde SOLO con JSON: {"ranking": ["coder", ...], "reasons": {"coder": "por qué"}, "tie": false}',
96
+ "Declara tie:true únicamente si de verdad no puedes distinguirlos.",
97
+ ].join("\n\n");
98
+
99
+ logger?.info?.(`torneo ${board.id}: juez=${judge} sobre ${finalists.length} finalistas`);
100
+ const res = await agentFn({ judge, prompt, config, logger });
101
+ if (!res?.ok) throw new Error(`tournament: el juez ${judge} falló: ${res?.error || "sin salida"}`);
102
+ let verdict;
103
+ try {
104
+ verdict = JSON.parse(String(res.output).replace(/^[^{]*/, "").replace(/[^}]*$/, ""));
105
+ } catch {
106
+ throw new Error(`tournament: el juez ${judge} no devolvió un veredicto parseable`);
107
+ }
108
+ // An LLM verdict is untrusted input: the ranking must name FINALISTS and
109
+ // nobody else — a hallucinated coder must fail loud, never get crowned.
110
+ const isFinalist = (c) => finalists.includes(c);
111
+ if (!Array.isArray(verdict.ranking) || verdict.ranking.length === 0 || !verdict.ranking.every(isFinalist)) {
112
+ throw new Error(`tournament: ranking del juez inválido (${JSON.stringify(verdict.ranking)}) — debe nombrar solo finalistas: ${finalists.join(", ")}`);
113
+ }
114
+
115
+ // Only FIRST place crowns a winner: a judge that reorders lower positions
116
+ // but agrees on who wins is agreement, not a dispute for solomon.
117
+ const judgeTop = verdict.ranking?.[0];
118
+ const boardTop = board.ranking[0];
119
+ const disagreement = judgeTop !== boardTop;
120
+ const escalated = Boolean(verdict.tie || disagreement);
121
+
122
+ let winner = judgeTop;
123
+ let solomon = null;
124
+ if (escalated) {
125
+ if (!solomonFn) {
126
+ throw new Error(
127
+ `tournament: ${verdict.tie ? "empate declarado" : `discrepancia juez(${judgeTop}) vs scoreboard(${boardTop})`} y sin árbitro configurado`
128
+ );
129
+ }
130
+ solomon = await solomonFn({
131
+ judgeRanking: verdict.ranking,
132
+ boardRanking: board.ranking,
133
+ reasons: verdict.reasons,
134
+ tie: Boolean(verdict.tie),
135
+ });
136
+ if (!isFinalist(solomon?.winner)) {
137
+ throw new Error(`tournament: solomon devolvió un ganador inválido (${solomon?.winner}) — debe ser finalista`);
138
+ }
139
+ winner = solomon.winner;
140
+ }
141
+
142
+ const judgement = {
143
+ winner, judge, escalated,
144
+ judge_ranking: verdict.ranking, judge_reasons: verdict.reasons || {},
145
+ board_ranking: board.ranking, solomon,
146
+ judged_at: new Date().toISOString(),
147
+ };
148
+ fs.writeFileSync(path.join(tournamentDir, "judgement.json"), JSON.stringify(judgement, null, 2));
149
+ logger?.info?.(`torneo ${board.id}: ganador=${winner}${escalated ? " (vía solomon)" : " (juez y scoreboard de acuerdo)"}`);
150
+ return judgement;
151
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * tournament/run — TOR-A (KJC-TSK-0723, epic KJC-PCS-0073).
3
+ *
4
+ * Orca's pitch is "fan one prompt across N agents, compare, merge the
5
+ * winner" — with a human eyeballing diffs. Karajan's tournament keeps the
6
+ * fan-out and replaces the eyeballing with the method: this module runs the
7
+ * SAME task through N coders, each in its own isolated worktree lane (the
8
+ * PAR machinery), and collects per-lane evidence (diff, suite result, agent
9
+ * log, metadata) for the deterministic scoreboard (TOR-B) and the governed
10
+ * judgement/merge (TOR-C). No commits here: lanes stay as working trees.
11
+ *
12
+ * A COMPOSER, not a new pipeline: lanes = `git worktree` under
13
+ * .kj/worktrees (same dir as kj worktree / headless lanes); agents = the
14
+ * registered adapters with the PAR idiom (laneConfig.projectDir = lane,
15
+ * task.cwd = lane); suite = the package manager the repo actually uses.
16
+ * Every dependency is injectable; one lane's failure never sinks another.
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ import { createAgent } from "../agents/index.js";
23
+ import { bootstrapWorktree } from "../hu/worktree-bootstrap.js";
24
+ import { detectPackageManager } from "../harden/workflow-engine.js";
25
+ import { PM_COMMANDS } from "../harden/workflow-templates.js";
26
+
27
+ const execFileAsync = promisify(execFile);
28
+ const WORKTREE_DIR = ".kj/worktrees"; // shared with kj worktree + headless lanes
29
+ const TOURNAMENT_DIR = ".kj/tournaments";
30
+ const SUITE_TIMEOUT_MS = 15 * 60 * 1000;
31
+
32
+ const defaultGit = (args, cwd) => execFileAsync("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 });
33
+
34
+ async function defaultRunSuite({ lanePath }) {
35
+ const pm = detectPackageManager(lanePath);
36
+ const testCmd = (PM_COMMANDS[pm] || PM_COMMANDS.npm).test.split(" ");
37
+ try {
38
+ const res = await execFileAsync(testCmd[0], testCmd.slice(1), {
39
+ cwd: lanePath, timeout: SUITE_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
40
+ });
41
+ return { ok: true, exitCode: 0, output: res.stdout || "" };
42
+ } catch (err) {
43
+ return { ok: false, exitCode: err.code ?? 1, output: `${err.stdout || ""}\n${err.stderr || ""}` };
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Fan the task out to every coder and collect per-lane results.
49
+ * @returns {Promise<{id: string, dir: string, lanes: Array<object>}>}
50
+ */
51
+ export async function runTournament({ task, coders = [], config = {}, logger = console, deps = {} }) {
52
+ const {
53
+ createAgentFn = createAgent,
54
+ gitFn = defaultGit,
55
+ bootstrapFn = bootstrapWorktree,
56
+ runSuiteFn = defaultRunSuite,
57
+ } = deps;
58
+ if (!task || !String(task).trim()) throw new Error("tournament: falta la tarea");
59
+ if (!Array.isArray(coders) || coders.length < 2) {
60
+ throw new Error("tournament: hacen falta al menos 2 coders (--coders a,b) — con uno no hay torneo");
61
+ }
62
+ // Coder names feed lane/result PATHS: a strict slug (no separators, no
63
+ // leading dot, no traversal) before any filesystem use — codex's catch.
64
+ const CODER_RE = /^[a-z0-9][a-z0-9_-]{0,23}$/i;
65
+ for (const c of coders) {
66
+ if (!CODER_RE.test(String(c))) {
67
+ throw new Error(`tournament: nombre de coder inválido "${c}" — solo letras, dígitos, guiones (sin rutas)`);
68
+ }
69
+ }
70
+
71
+ const projectDir = config.projectDir || process.cwd();
72
+ const id = `tor-${Date.now().toString(36)}`;
73
+ const resultsRoot = path.join(projectDir, TOURNAMENT_DIR, id);
74
+ fs.mkdirSync(resultsRoot, { recursive: true });
75
+ logger?.info?.(`kj tournament ${id}: ${coders.length} coders → ${coders.join(", ")}`);
76
+
77
+ const settled = await Promise.allSettled(coders.map((coder) => runLane({
78
+ coder, task, id, projectDir, resultsRoot, config, logger,
79
+ createAgentFn, gitFn, bootstrapFn, runSuiteFn,
80
+ })));
81
+
82
+ const lanes = settled.map((s, i) =>
83
+ s.status === "fulfilled"
84
+ ? s.value
85
+ : { coder: coders[i], status: "failed", error: String(s.reason?.message || s.reason), dir: path.join(resultsRoot, coders[i]) },
86
+ );
87
+
88
+ const summary = { id, task: String(task), created_at: new Date().toISOString(), lanes };
89
+ fs.writeFileSync(path.join(resultsRoot, "summary.json"), JSON.stringify(summary, null, 2));
90
+ for (const lane of lanes) {
91
+ let state = `FAILED: ${lane.error}`;
92
+ if (lane.status === "completed") state = lane.suite?.ok ? "suite verde" : "suite ROJA";
93
+ logger?.info?.(` ${lane.coder.padEnd(10)} ${lane.status.padEnd(10)} ${state}`);
94
+ }
95
+ return { id, dir: resultsRoot, lanes };
96
+ }
97
+
98
+ async function runLane({ coder, task, id, projectDir, resultsRoot, config, logger, createAgentFn, gitFn, bootstrapFn, runSuiteFn }) {
99
+ const slug = `${id}-${coder}`;
100
+ const lanePath = path.join(projectDir, WORKTREE_DIR, slug);
101
+ const branch = `tor/${slug}`;
102
+ const laneDir = path.join(resultsRoot, coder);
103
+ fs.mkdirSync(laneDir, { recursive: true });
104
+
105
+ await gitFn(["worktree", "add", "-b", branch, lanePath], projectDir);
106
+ await bootstrapFn({ worktreePath: lanePath, setupCommand: config?.session?.worktree_setup || null });
107
+
108
+ // The PAR idiom: the lane IS the project for this agent. A tournament
109
+ // coder MUST be able to write files — found live in the first real run:
110
+ // codex without auto_approve produced an honest but useless EMPTY diff.
111
+ const laneConfig = {
112
+ ...config,
113
+ projectDir: lanePath,
114
+ coder_options: { ...config?.coder_options, auto_approve: true },
115
+ };
116
+ const prompt =
117
+ `You are one of ${"several"} coders solving the SAME task independently. ` +
118
+ `Work ONLY inside this directory (your isolated worktree). Implement the task with tests. Do not commit.\n\nTASK:\n${task}`;
119
+ const started = Date.now();
120
+ const result = await createAgentFn(coder, laneConfig, logger).runTask({ prompt, role: "coder", cwd: lanePath });
121
+ fs.writeFileSync(path.join(laneDir, "agent.log"), String(result?.output ?? ""));
122
+ if (!result?.ok) throw new Error(`coder ${coder} failed: ${result?.error || "no output"}`);
123
+
124
+ // Intent-to-add first: plain `git diff` is blind to UNTRACKED files, and
125
+ // new files are the most common coder output — codex's catch.
126
+ await gitFn(["add", "-N", "."], lanePath);
127
+ const diff = await gitFn(["diff"], lanePath);
128
+ fs.writeFileSync(path.join(laneDir, "diff.patch"), diff.stdout || "");
129
+
130
+ const suite = await runSuiteFn({ lanePath });
131
+ fs.writeFileSync(path.join(laneDir, "suite.log"), suite.output || "");
132
+
133
+ const meta = {
134
+ coder, branch, lane: lanePath, tournament: id,
135
+ duration_ms: Date.now() - started,
136
+ diff_bytes: (diff.stdout || "").length,
137
+ suite: { ok: suite.ok, exitCode: suite.exitCode },
138
+ };
139
+ fs.writeFileSync(path.join(laneDir, "meta.json"), JSON.stringify(meta, null, 2));
140
+
141
+ return { coder, status: "completed", branch, lane: lanePath, dir: laneDir, suite: { ok: suite.ok, exitCode: suite.exitCode } };
142
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * tournament/scoreboard — TOR-B (KJC-TSK-0724, epic KJC-PCS-0073).
3
+ *
4
+ * The deterministic half of "merge the winner": pure metrics computed from
5
+ * the artifacts TOR-A leaves under .kj/tournaments/<id>/ (suite verdict,
6
+ * net LOC, tests touched — parsed from diff.patch), plus injectable
7
+ * external metrics (mutation via `kj mutate` in the still-alive lane;
8
+ * sonar reserved) that degrade HONESTLY to null-with-note when their tool
9
+ * is unavailable — a fake zero would poison the ranking. Red suites are
10
+ * eliminated, never finalists. The ranking rule is lexicographic and
11
+ * PRINTED with the result: no opaque composite scores, no hidden weights.
12
+ * Zero LLM in this whole phase.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+ const TESTS_RE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[a-z]+$/;
21
+ const MUTATE_TIMEOUT_MS = 10 * 60 * 1000;
22
+
23
+ // EXACT unified-diff header forms — an added line whose content starts with
24
+ // "++" produces "+++<content>" and must still COUNT (codex's catch): only
25
+ // `+++ b/…`, `+++ /dev/null`, `--- a/…`, `--- /dev/null` are headers.
26
+ const ADD_HEADER_RE = /^\+\+\+ (b\/|\/dev\/null)/;
27
+ const DEL_HEADER_RE = /^--- (a\/|\/dev\/null)/;
28
+
29
+ /** Pure unified-diff stats: files, additions/deletions/net, tests touched. */
30
+ export function parseDiffStats(patch) {
31
+ const files = [];
32
+ let additions = 0;
33
+ let deletions = 0;
34
+ for (const line of String(patch || "").split("\n")) {
35
+ if (ADD_HEADER_RE.test(line)) {
36
+ if (line.startsWith("+++ b/")) files.push(line.slice(6).trim());
37
+ } else if (DEL_HEADER_RE.test(line)) {
38
+ // deletion-side header — never a counted line
39
+ } else if (line.startsWith("+")) additions++;
40
+ else if (line.startsWith("-")) deletions++;
41
+ }
42
+ const testsTouched = files.filter((f) => TESTS_RE.test(f)).length;
43
+ return { files, additions, deletions, net: additions - deletions, testsTouched };
44
+ }
45
+
46
+ export async function defaultMutate({ lane }) {
47
+ // Mutation runs IN the live worktree lane — codex's catch: guard the path
48
+ // explicitly so a gone/relative lane yields a DISTINCT note instead of
49
+ // silently degrading the ranking.
50
+ if (!lane || !path.isAbsolute(lane) || !fs.existsSync(lane)) {
51
+ return { score: null, note: "lane no longer exists — score after the fan-out, before cleaning lanes" };
52
+ }
53
+ try {
54
+ const res = await execFileAsync("kj", ["mutate", "--json"], { cwd: lane, timeout: MUTATE_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
55
+ const parsed = JSON.parse(res.stdout);
56
+ return { score: parsed.score ?? null, killed: parsed.killed ?? null, survived: parsed.survived ?? null };
57
+ } catch {
58
+ return null; // kj unavailable / no mutants / timeout — honest n/a
59
+ }
60
+ }
61
+
62
+ // Sonar-per-lane needs the running-server collector; reserved as a future
63
+ // step (see card). Injectable so TOR-C or tests can supply real findings.
64
+ const defaultSonar = async () => null;
65
+
66
+ /**
67
+ * Score every lane of a tournament from its artifacts.
68
+ * @returns {Promise<{id: string, rows: Array<object>, ranking: string[], rule: string}>}
69
+ */
70
+ export async function scoreTournament({ tournamentDir, deps = {} }) {
71
+ const { mutateFn = defaultMutate, sonarFn = defaultSonar } = deps;
72
+ const summary = JSON.parse(fs.readFileSync(path.join(tournamentDir, "summary.json"), "utf8"));
73
+
74
+ const rows = [];
75
+ for (const lane of summary.lanes) {
76
+ if (lane.status !== "completed") {
77
+ rows.push({ coder: lane.coder, finalist: false, eliminated_by: "failed", error: lane.error || null });
78
+ continue;
79
+ }
80
+ const laneDir = lane.dir || path.join(tournamentDir, lane.coder);
81
+ const meta = JSON.parse(fs.readFileSync(path.join(laneDir, "meta.json"), "utf8"));
82
+ const loc = parseDiffStats(fs.readFileSync(path.join(laneDir, "diff.patch"), "utf8"));
83
+
84
+ let mutation = { score: null, note: "not available" };
85
+ try {
86
+ const m = await mutateFn({ coder: lane.coder, lane: meta.lane, laneDir });
87
+ if (m && typeof m.score === "number") mutation = { ...m, note: null };
88
+ else if (m?.note) mutation = { score: null, note: m.note };
89
+ } catch { /* honest n/a — never a fake zero */ }
90
+ const sonar = await sonarFn({ coder: lane.coder, lane: meta.lane, files: loc.files }).catch(() => null);
91
+
92
+ const suiteOk = Boolean(meta.suite?.ok);
93
+ rows.push({
94
+ coder: lane.coder,
95
+ finalist: suiteOk,
96
+ eliminated_by: suiteOk ? null : "suite",
97
+ suite: meta.suite,
98
+ loc,
99
+ mutation,
100
+ sonar,
101
+ });
102
+ }
103
+
104
+ const anyMutation = rows.some((r) => typeof r.mutation?.score === "number");
105
+ const rule = anyMutation
106
+ ? "finalistas = suite verde; orden: mutation desc SOLO entre carriles que la tienen (null jamás penaliza — codex's catch), luego LOC neto asc"
107
+ : "finalistas = suite verde; orden: LOC neto asc (mutation no disponible)";
108
+
109
+ const ranking = rows
110
+ .filter((r) => r.finalist)
111
+ .sort((a, b) => {
112
+ const ma = a.mutation?.score;
113
+ const mb = b.mutation?.score;
114
+ // Mutation only compares when BOTH lanes have a real score — a null
115
+ // (tool unavailable) must stay a note, never become a hidden penalty.
116
+ if (typeof ma === "number" && typeof mb === "number" && mb !== ma) return mb - ma;
117
+ return a.loc.net - b.loc.net;
118
+ })
119
+ .map((r) => r.coder);
120
+
121
+ const board = { id: summary.id, task: summary.task, rule, ranking, rows, scored_at: new Date().toISOString() };
122
+ fs.writeFileSync(path.join(tournamentDir, "scoreboard.json"), JSON.stringify(board, null, 2));
123
+ return board;
124
+ }