karajan-code 4.14.0 → 4.16.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/package.json +1 -1
- package/packages/ai-trash/src/install.js +22 -1
- package/scripts/verify-pack.mjs +28 -6
- package/src/agents/agy-agent.js +1 -0
- package/src/agents/base-agent.js +1 -1
- package/src/agents/claude-agent.js +3 -2
- package/src/agents/codex-agent.js +3 -1
- package/src/agents/kimi-agent.js +1 -0
- package/src/checks/release-check.js +10 -17
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-pipeline.js +26 -4
- package/src/commands/env.js +1 -1
- package/src/commands/harden.js +4 -1
- package/src/commands/rag.js +4 -4
- package/src/commands/review-gate.js +3 -0
- package/src/commands/tournament.js +85 -0
- package/src/config/defaults.js +3 -1
- package/src/environment/board-select.js +2 -1
- package/src/harden/config-engine.js +27 -0
- package/src/harden/config-templates.js +8 -3
- package/src/harden/hook-templates.js +2 -2
- package/src/harden/workflow-engine.js +11 -1
- package/src/harden/workflow-templates.js +41 -22
- package/src/orchestrator/direct-actions.js +12 -1
- package/src/prompts/diff-clip.js +27 -0
- package/src/prompts/reviewer.js +8 -2
- package/src/prompts/split-signal.js +82 -0
- package/src/review/card-first.js +19 -5
- package/src/review/card-verify.js +83 -0
- package/src/roles/reviewer-role.js +9 -8
- package/src/sonar/scanner.js +11 -7
- package/src/tournament/crown.js +88 -0
- package/src/tournament/judge.js +150 -0
- package/src/tournament/run.js +142 -0
- package/src/tournament/scoreboard.js +124 -0
- package/src/utils/update-check.js +2 -1
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
const why = verdict.tie ? "empate declarado" : `discrepancia juez(${judgeTop}) vs scoreboard(${boardTop})`;
|
|
127
|
+
throw new Error(`tournament: ${why} y sin árbitro configurado`);
|
|
128
|
+
}
|
|
129
|
+
solomon = await solomonFn({
|
|
130
|
+
judgeRanking: verdict.ranking,
|
|
131
|
+
boardRanking: board.ranking,
|
|
132
|
+
reasons: verdict.reasons,
|
|
133
|
+
tie: Boolean(verdict.tie),
|
|
134
|
+
});
|
|
135
|
+
if (!isFinalist(solomon?.winner)) {
|
|
136
|
+
throw new Error(`tournament: solomon devolvió un ganador inválido (${solomon?.winner}) — debe ser finalista`);
|
|
137
|
+
}
|
|
138
|
+
winner = solomon.winner;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const judgement = {
|
|
142
|
+
winner, judge, escalated,
|
|
143
|
+
judge_ranking: verdict.ranking, judge_reasons: verdict.reasons || {},
|
|
144
|
+
board_ranking: board.ranking, solomon,
|
|
145
|
+
judged_at: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
fs.writeFileSync(path.join(tournamentDir, "judgement.json"), JSON.stringify(judgement, null, 2));
|
|
148
|
+
logger?.info?.(`torneo ${board.id}: ganador=${winner}${escalated ? " (vía solomon)" : " (juez y scoreboard de acuerdo)"}`);
|
|
149
|
+
return judgement;
|
|
150
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -17,7 +17,8 @@ const HIGHLIGHT_MAX = 160;
|
|
|
17
17
|
* ("Minor. **The method, enforced.** …"), bold stripped, truncated. Pure.
|
|
18
18
|
*/
|
|
19
19
|
export function parseChangelogHighlight(markdown, version) {
|
|
20
|
-
const
|
|
20
|
+
const escapedVersion = version.replaceAll(".", String.raw`\.`);
|
|
21
|
+
const section = String(markdown).split(new RegExp(String.raw`^## \[${escapedVersion}\][^\n]*$`, "m"))[1];
|
|
21
22
|
if (!section) return null;
|
|
22
23
|
const firstLine = section.split(/^##|^###/m)[0].split("\n").map((l) => l.trim()).find(Boolean);
|
|
23
24
|
if (!firstLine) return null;
|