karajan-code 4.14.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.
- package/package.json +1 -1
- package/src/agents/agy-agent.js +1 -0
- 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/cli/advanced-commands.js +1 -1
- package/src/cli/register-pipeline.js +26 -4
- package/src/commands/harden.js +1 -1
- package/src/commands/tournament.js +85 -0
- package/src/harden/config-engine.js +27 -0
- package/src/harden/config-templates.js +8 -3
- package/src/harden/workflow-engine.js +11 -1
- package/src/harden/workflow-templates.js +41 -22
- 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/roles/reviewer-role.js +9 -8
- package/src/tournament/crown.js +88 -0
- package/src/tournament/judge.js +151 -0
- package/src/tournament/run.js +142 -0
- package/src/tournament/scoreboard.js +124 -0
package/package.json
CHANGED
package/src/agents/agy-agent.js
CHANGED
|
@@ -37,6 +37,7 @@ export class AgyAgent extends BaseAgent {
|
|
|
37
37
|
onOutput: task.onOutput,
|
|
38
38
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
39
39
|
timeout: task.timeoutMs,
|
|
40
|
+
cwd: task.cwd,
|
|
40
41
|
});
|
|
41
42
|
const parsed = extractAgyOutput(res.stdout);
|
|
42
43
|
const ok = res.exitCode === 0 && parsed?.status === "SUCCESS";
|
|
@@ -374,7 +374,8 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
374
374
|
onOutput: streamFilter,
|
|
375
375
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
376
376
|
timeout: task.timeoutMs,
|
|
377
|
-
env: task.env
|
|
377
|
+
env: task.env,
|
|
378
|
+
cwd: task.cwd
|
|
378
379
|
}));
|
|
379
380
|
const raw = pickOutput(res);
|
|
380
381
|
const output = extractTextFromStreamJson(raw);
|
|
@@ -384,7 +385,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
384
385
|
|
|
385
386
|
// Without streaming, use json output to get structured response via stderr
|
|
386
387
|
args.push("--output-format", "json");
|
|
387
|
-
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: task.env }));
|
|
388
|
+
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: task.env, cwd: task.cwd }));
|
|
388
389
|
const raw = pickOutput(res);
|
|
389
390
|
const output = extractTextFromStreamJson(raw);
|
|
390
391
|
const usage = extractUsageFromStreamJson(raw);
|
|
@@ -85,7 +85,9 @@ export class CodexAgent extends BaseAgent {
|
|
|
85
85
|
onOutput: task.onOutput,
|
|
86
86
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
87
87
|
timeout: task.timeoutMs,
|
|
88
|
-
input: task.prompt
|
|
88
|
+
input: task.prompt,
|
|
89
|
+
// TOR-A (KJC-TSK-0723): tournament lanes run the coder INSIDE the lane.
|
|
90
|
+
cwd: task.cwd
|
|
89
91
|
});
|
|
90
92
|
const usage = extractCodexTokens(res.stdout);
|
|
91
93
|
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
package/src/agents/kimi-agent.js
CHANGED
|
@@ -38,6 +38,7 @@ export class KimiAgent extends BaseAgent {
|
|
|
38
38
|
onOutput: task.onOutput,
|
|
39
39
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
40
40
|
timeout: task.timeoutMs,
|
|
41
|
+
cwd: task.cwd,
|
|
41
42
|
});
|
|
42
43
|
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
|
|
43
44
|
}
|
|
@@ -27,7 +27,7 @@ export const META_COMMANDS = ["advanced", "help"];
|
|
|
27
27
|
* so a newly-registered command can never silently vanish from `kj advanced`.
|
|
28
28
|
*/
|
|
29
29
|
export const ADVANCED_GROUPS = [
|
|
30
|
-
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan"] },
|
|
30
|
+
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
31
31
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
32
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
33
|
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release"] },
|
|
@@ -3,6 +3,8 @@ import { ollamaStartCommand, ollamaStopCommand, ollamaStatusCommand, ollamaPullC
|
|
|
3
3
|
import { configCommand } from "../commands/config.js";
|
|
4
4
|
import { codeCommand } from "../commands/code.js";
|
|
5
5
|
import { reviewCommand } from "../commands/review.js";
|
|
6
|
+
import { tournamentCommand } from "../commands/tournament.js";
|
|
7
|
+
import { resolveTaskInput } from "../utils/task-file.js";
|
|
6
8
|
import { reviewGateCommand, solomonCommand } from "../commands/review-gate.js";
|
|
7
9
|
import { scanCommand } from "../commands/scan.js";
|
|
8
10
|
import { installToolsCommand } from "../commands/install-tools.js";
|
|
@@ -144,7 +146,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
144
146
|
effectiveTask = plan.task;
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
148
149
|
const resolvedTask = await resolveTaskInput({ task: effectiveTask, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
149
150
|
const res = await runCommandHandler({ task: resolvedTask, config, logger, flags });
|
|
150
151
|
// KJC-TSK-0674 (issue #1289): an aborted run (spec-review cancel,
|
|
@@ -163,7 +164,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
163
164
|
.option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
|
|
164
165
|
.action(async (task, flags) => {
|
|
165
166
|
const code = await withConfig(pkgVersion, "autorun", flags, async ({ config, logger }) => {
|
|
166
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
167
167
|
const { autorunCommand } = await import("../commands/autorun.js");
|
|
168
168
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
169
169
|
const res = await autorunCommand({ task: resolvedTask, config, logger, flags });
|
|
@@ -181,7 +181,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
181
181
|
.option("--coder-model <name>")
|
|
182
182
|
.action(async (task, flags) => {
|
|
183
183
|
await withConfig(pkgVersion, "code", flags, async ({ config, logger }) => {
|
|
184
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
185
184
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
186
185
|
await codeCommand({ task: resolvedTask, config, logger });
|
|
187
186
|
});
|
|
@@ -199,6 +198,30 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
199
198
|
});
|
|
200
199
|
});
|
|
201
200
|
|
|
201
|
+
// KJC-TSK-0723 (TOR-A) — the governed tournament: same task, N coders,
|
|
202
|
+
// isolated lanes; the method picks the winner (TOR-B/C), not vibes.
|
|
203
|
+
program
|
|
204
|
+
.command("tournament")
|
|
205
|
+
.description("Fan the SAME task out to N coders in isolated worktree lanes and collect per-lane evidence (diff, suite, log)")
|
|
206
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
207
|
+
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
208
|
+
.option("--coders <csv>", "Comma-separated coder agents (minimum 2), e.g. claude,codex,agy")
|
|
209
|
+
.option("--score <id>", "Score an EXISTING tournament from its artifacts (deterministic, zero LLM)")
|
|
210
|
+
.option("--judge <id>", "Cross-AI judge ranks the finalists (scoreboard in sight; solomon on tie/dispute)")
|
|
211
|
+
.option("--crown <id>", "Commit the judged winner in its lane THROUGH the real review gate")
|
|
212
|
+
.option("--card <ref>", "With --crown: card reference for the commit message (card-first gates)")
|
|
213
|
+
.option("--json", "With --score/--judge/--crown: emit stable JSON")
|
|
214
|
+
.action(async (task, flags) => {
|
|
215
|
+
await withConfig(pkgVersion, "tournament", flags, async ({ config, logger }) => {
|
|
216
|
+
if (flags.score || flags.judge || flags.crown) {
|
|
217
|
+
await tournamentCommand({ task: "", config, logger, flags });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
221
|
+
await tournamentCommand({ task: resolvedTask, config, logger, flags });
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
202
225
|
program
|
|
203
226
|
.command("review")
|
|
204
227
|
.description("Run only reviewer (--staged/--check: v4 cross-AI gate with recorded verdict)")
|
|
@@ -220,7 +243,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
220
243
|
await reviewGateCommand({ config, logger, flags: { ...flags, task } });
|
|
221
244
|
return;
|
|
222
245
|
}
|
|
223
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
224
246
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
225
247
|
await reviewCommand({ task: resolvedTask, config, logger, baseRef: flags.baseRef });
|
|
226
248
|
});
|
package/src/commands/harden.js
CHANGED
|
@@ -184,7 +184,7 @@ export async function hardenCommand({
|
|
|
184
184
|
const verb = dryRun ? "would install" : "installed";
|
|
185
185
|
logger.info?.(`kj harden (${profile}) — ${verb} ${result.hooks.length} hook(s) → ${result.hooksPath}`);
|
|
186
186
|
for (const h of result.hooks) logger.info?.(` • ${h.hook}: ${h.action}`);
|
|
187
|
-
for (const c of out.configs) logger.info?.(` • ${c.file}: ${c.action}`);
|
|
187
|
+
for (const c of out.configs) logger.info?.(` • ${c.file}: ${c.action}${c.note ? ` (${c.note})` : ""}`);
|
|
188
188
|
for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
|
|
189
189
|
for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
|
|
190
190
|
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj tournament` (KJC-TSK-0723, TOR-A) — fan the SAME task out to N coders
|
|
3
|
+
* in N isolated worktree lanes and collect the evidence. The deterministic
|
|
4
|
+
* scoreboard (TOR-B) and the governed judgement/merge (TOR-C) build on the
|
|
5
|
+
* artifacts this command leaves under .kj/tournaments/<id>/.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { runTournament } from "../tournament/run.js";
|
|
9
|
+
import { scoreTournament } from "../tournament/scoreboard.js";
|
|
10
|
+
import { judgeTournament } from "../tournament/judge.js";
|
|
11
|
+
import { crownWinner } from "../tournament/crown.js";
|
|
12
|
+
|
|
13
|
+
// The id feeds a path — same traversal class codex caught in TOR-A's coder
|
|
14
|
+
// names: strict slug, no separators, before any filesystem use.
|
|
15
|
+
function tournamentDirFor(config, rawId) {
|
|
16
|
+
const id = String(rawId);
|
|
17
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(id)) {
|
|
18
|
+
throw new Error(`tournament: id inválido "${id}" — usa el id que imprimió el torneo (p. ej. tor-abc123)`);
|
|
19
|
+
}
|
|
20
|
+
return path.join(config?.projectDir || process.cwd(), ".kj", "tournaments", id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function tournamentCommand({
|
|
24
|
+
task, config, logger = console, flags = {},
|
|
25
|
+
runTournamentFn = runTournament, scoreTournamentFn = scoreTournament,
|
|
26
|
+
judgeTournamentFn = judgeTournament, crownWinnerFn = crownWinner,
|
|
27
|
+
}) {
|
|
28
|
+
// TOR-C: `--judge <id>` ranks the finalists; `--crown <id>` commits the
|
|
29
|
+
// winner through the real gate in its lane.
|
|
30
|
+
if (flags.judge) {
|
|
31
|
+
const judgement = await judgeTournamentFn({ tournamentDir: tournamentDirFor(config, flags.judge), config, logger });
|
|
32
|
+
if (flags.json) logger?.info?.(JSON.stringify(judgement));
|
|
33
|
+
else logger?.info?.(` siguiente: kj tournament --crown ${flags.judge}${judgement.escalated ? " (ganador vía solomon)" : ""}`);
|
|
34
|
+
return judgement;
|
|
35
|
+
}
|
|
36
|
+
if (flags.crown) {
|
|
37
|
+
const crown = await crownWinnerFn({
|
|
38
|
+
tournamentDir: tournamentDirFor(config, flags.crown), config, logger, cardRef: flags.card || null,
|
|
39
|
+
});
|
|
40
|
+
if (flags.json) logger?.info?.(JSON.stringify(crown));
|
|
41
|
+
return crown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// TOR-B: `--score <id>` scores an EXISTING tournament from its artifacts.
|
|
45
|
+
if (flags.score) {
|
|
46
|
+
const tournamentDir = tournamentDirFor(config, flags.score);
|
|
47
|
+
const board = await scoreTournamentFn({ tournamentDir });
|
|
48
|
+
if (flags.json) {
|
|
49
|
+
logger?.info?.(JSON.stringify(board));
|
|
50
|
+
return board;
|
|
51
|
+
}
|
|
52
|
+
logger?.info?.(`scoreboard ${board.id} — regla: ${board.rule}`);
|
|
53
|
+
for (const r of board.rows) {
|
|
54
|
+
if (!r.finalist) {
|
|
55
|
+
logger?.info?.(` ${r.coder.padEnd(10)} eliminado (${r.eliminated_by})`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const mut = typeof r.mutation?.score === "number" ? `${r.mutation.score}%` : "n/a";
|
|
59
|
+
logger?.info?.(` ${r.coder.padEnd(10)} FINALISTA loc:${String(r.loc.net).padStart(5)} tests:${r.loc.testsTouched} mutation:${mut}`);
|
|
60
|
+
}
|
|
61
|
+
logger?.info?.(` ranking: ${board.ranking.join(" > ") || "(sin finalistas)"}`);
|
|
62
|
+
return board;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!task || !String(task).trim()) {
|
|
66
|
+
throw new Error("tournament: falta la tarea — kj tournament \"<tarea>\" --coders claude,codex");
|
|
67
|
+
}
|
|
68
|
+
const coders = String(flags.coders || "")
|
|
69
|
+
.split(",")
|
|
70
|
+
.map((c) => c.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
if (coders.length < 2) {
|
|
73
|
+
throw new Error("tournament: hacen falta al menos 2 en --coders — p. ej. --coders claude,codex,agy (con uno no hay torneo)");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const res = await runTournamentFn({ task, coders, config, logger });
|
|
77
|
+
|
|
78
|
+
const completed = res.lanes.filter((l) => l.status === "completed");
|
|
79
|
+
const green = completed.filter((l) => l.suite?.ok);
|
|
80
|
+
logger?.info?.("");
|
|
81
|
+
logger?.info?.(`torneo ${res.id}: ${completed.length}/${res.lanes.length} carriles completados, ${green.length} con suite verde`);
|
|
82
|
+
logger?.info?.(` evidencia: ${res.dir}`);
|
|
83
|
+
logger?.info?.(" siguiente: el scoreboard determinista (TOR-B) puntuará los carriles; de momento compara los diff.patch a mano.");
|
|
84
|
+
return res;
|
|
85
|
+
}
|
|
@@ -15,6 +15,24 @@ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
|
|
|
15
15
|
|
|
16
16
|
const BLOCK_VERSION = 1;
|
|
17
17
|
|
|
18
|
+
// KJC-BUG-0137 (issue #1357): is the tool a config demands actually installed?
|
|
19
|
+
// Declared dependency or a resolvable bin both count; an unreadable
|
|
20
|
+
// package.json is no evidence of the tool.
|
|
21
|
+
function hasTool(dirs, tool) {
|
|
22
|
+
for (const d of dirs) {
|
|
23
|
+
if (existsSync(join(d, "node_modules", ".bin", tool))) return true;
|
|
24
|
+
const pkgPath = join(d, "package.json");
|
|
25
|
+
if (!existsSync(pkgPath)) continue;
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
28
|
+
if (pkg.devDependencies?.[tool] || pkg.dependencies?.[tool]) return true;
|
|
29
|
+
} catch {
|
|
30
|
+
/* unreadable ⇒ keep looking */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
function writeFile(target, content) {
|
|
19
37
|
mkdirSync(dirname(target), { recursive: true });
|
|
20
38
|
writeFileSync(target, content);
|
|
@@ -47,6 +65,15 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
|
|
|
47
65
|
results.push({ file, action: "covered", by: variant });
|
|
48
66
|
continue;
|
|
49
67
|
}
|
|
68
|
+
// KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
|
|
69
|
+
if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
|
|
70
|
+
results.push({
|
|
71
|
+
file,
|
|
72
|
+
action: "omitted",
|
|
73
|
+
note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden`,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
if (cfg.json) {
|
|
@@ -95,10 +95,15 @@ export const UNIVERSAL_CONFIGS = [
|
|
|
95
95
|
{ file: "commitlint.config.js", blockId: "commitlint", style: "slash", body: COMMITLINT_BODY },
|
|
96
96
|
];
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* `json:true` ⇒ seed-only (no comment syntax for a marker). `requires` names
|
|
100
|
+
* the tool the config is for: absent from the project ⇒ the config is omitted
|
|
101
|
+
* with the install command in the report (KJC-BUG-0137) — a config without its
|
|
102
|
+
* tool is decorative, and kj never generates a demand it didn't satisfy.
|
|
103
|
+
*/
|
|
99
104
|
export const JS_CONFIGS = [
|
|
100
|
-
{ file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY },
|
|
101
|
-
{ file: ".prettierrc.json", json: true, body: PRETTIER_BODY },
|
|
105
|
+
{ file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY, requires: "eslint" },
|
|
106
|
+
{ file: ".prettierrc.json", json: true, body: PRETTIER_BODY, requires: "prettier" },
|
|
102
107
|
];
|
|
103
108
|
export const PY_CONFIGS = [{ file: "ruff.toml", blockId: "ruff", style: "hash", body: RUFF_BODY }];
|
|
104
109
|
export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style: "hash", body: GOLANGCI_BODY }];
|
|
@@ -34,6 +34,16 @@ export function detectPackageManager(projectDir) {
|
|
|
34
34
|
return "npm";
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// KJC-BUG-0137: the JS quality workflow only carries a lint step when the
|
|
38
|
+
// project HAS a lint script — and then it enforces (no --if-present).
|
|
39
|
+
function hasLintScript(projectDir) {
|
|
40
|
+
try {
|
|
41
|
+
return Boolean(JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")).scripts?.lint);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
export function installWorkflows({
|
|
38
48
|
projectDir = process.cwd(),
|
|
39
49
|
language = null,
|
|
@@ -43,7 +53,7 @@ export function installWorkflows({
|
|
|
43
53
|
} = {}) {
|
|
44
54
|
const dir = join(projectDir, WORKFLOWS_DIR);
|
|
45
55
|
const pm = detectPackageManager(projectDir);
|
|
46
|
-
const quality = qualityWorkflowFor(language, pm);
|
|
56
|
+
const quality = qualityWorkflowFor(language, pm, hasLintScript(projectDir));
|
|
47
57
|
const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir), pm });
|
|
48
58
|
const mut = mutation ? mutationWorkflowFor(language, pm) : null;
|
|
49
59
|
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : [])];
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
// Blocks AI self-attribution in PR commit messages. Pure bash + grep, no Node.
|
|
10
10
|
// Pins github.base_ref to an env var (never interpolated into the run block).
|
|
11
|
+
// KJC-BUG-0136 (issue #1374): actions pinned to full commit SHAs — a
|
|
12
|
+
// mutable tag lets its owner run arbitrary code inside EVERY hardened
|
|
13
|
+
// repo's CI, with access to the workflow's secrets; 5 of this project's 7
|
|
14
|
+
// audit findings came from files kj itself generated. The version rides in
|
|
15
|
+
// a comment (the practice kj audit demands). To bump one:
|
|
16
|
+
// gh api repos/<owner>/<repo>/commits/<tag> --jq .sha
|
|
17
|
+
export const PINNED_ACTIONS = {
|
|
18
|
+
checkout: "actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4",
|
|
19
|
+
setupNode: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4",
|
|
20
|
+
setupPython: "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5",
|
|
21
|
+
setupGo: "actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5",
|
|
22
|
+
setupPhp: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2",
|
|
23
|
+
};
|
|
24
|
+
|
|
11
25
|
export const NO_AI_ATTRIBUTION_WORKFLOW = [
|
|
12
26
|
"name: Block AI attribution",
|
|
13
27
|
"on:",
|
|
@@ -19,7 +33,7 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
|
|
|
19
33
|
" scan:",
|
|
20
34
|
" runs-on: ubuntu-latest",
|
|
21
35
|
" steps:",
|
|
22
|
-
|
|
36
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
23
37
|
" with:",
|
|
24
38
|
" fetch-depth: 0",
|
|
25
39
|
" - name: Scan commit messages for AI attribution",
|
|
@@ -50,34 +64,35 @@ const header = (steps) =>
|
|
|
50
64
|
" quality:",
|
|
51
65
|
" runs-on: ubuntu-latest",
|
|
52
66
|
" steps:",
|
|
53
|
-
|
|
67
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
54
68
|
...steps,
|
|
55
69
|
].join("\n");
|
|
56
70
|
|
|
57
71
|
// Per-package-manager commands (KJC-BUG-0131, issue #1330): `npm ci` in a
|
|
58
72
|
// pnpm/yarn repo dies with EUSAGE (no package-lock.json). corepack ships with
|
|
59
73
|
// Node 22 and resolves the right pnpm/yarn from `packageManager` — no
|
|
60
|
-
// third-party action.
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
// third-party action. Yarn scripts run through `npm run` (scripts don't touch
|
|
75
|
+
// the lockfile). Lint ENFORCES — never --if-present: a step that passes when
|
|
76
|
+
// its script is missing protects nothing (KJC-BUG-0137, issue #1357); the
|
|
77
|
+
// engine omits the step instead when the project has no lint script.
|
|
78
|
+
export const PM_COMMANDS = {
|
|
79
|
+
npm: { setup: [], install: "npm ci", lint: "npm run -s lint", test: "npm test" },
|
|
65
80
|
pnpm: {
|
|
66
81
|
setup: [" - run: corepack enable"],
|
|
67
82
|
install: "pnpm install --frozen-lockfile",
|
|
68
|
-
lint: "pnpm run
|
|
83
|
+
lint: "pnpm run -s lint",
|
|
69
84
|
test: "pnpm test",
|
|
70
85
|
},
|
|
71
86
|
yarn: {
|
|
72
87
|
setup: [" - run: corepack enable"],
|
|
73
88
|
install: "yarn install --frozen-lockfile",
|
|
74
|
-
lint: "npm run -s lint
|
|
89
|
+
lint: "npm run -s lint",
|
|
75
90
|
test: "yarn test",
|
|
76
91
|
},
|
|
77
92
|
};
|
|
78
93
|
|
|
79
94
|
const nodeSetupSteps = (pm) => [
|
|
80
|
-
|
|
95
|
+
` - uses: ${PINNED_ACTIONS.setupNode}`,
|
|
81
96
|
" with:",
|
|
82
97
|
" node-version: 22",
|
|
83
98
|
...(PM_COMMANDS[pm] || PM_COMMANDS.npm).setup,
|
|
@@ -86,7 +101,7 @@ const nodeSetupSteps = (pm) => [
|
|
|
86
101
|
|
|
87
102
|
const QUALITY_BY_LANGUAGE = {
|
|
88
103
|
python: header([
|
|
89
|
-
|
|
104
|
+
` - uses: ${PINNED_ACTIONS.setupPython}`,
|
|
90
105
|
" with:",
|
|
91
106
|
" python-version: '3.12'",
|
|
92
107
|
" - run: pip install ruff pytest",
|
|
@@ -94,14 +109,14 @@ const QUALITY_BY_LANGUAGE = {
|
|
|
94
109
|
" - run: pytest",
|
|
95
110
|
]),
|
|
96
111
|
go: header([
|
|
97
|
-
|
|
112
|
+
` - uses: ${PINNED_ACTIONS.setupGo}`,
|
|
98
113
|
" with:",
|
|
99
114
|
" go-version: stable",
|
|
100
115
|
" - run: go vet ./...",
|
|
101
116
|
" - run: go test ./...",
|
|
102
117
|
]),
|
|
103
118
|
php: header([
|
|
104
|
-
|
|
119
|
+
` - uses: ${PINNED_ACTIONS.setupPhp}`,
|
|
105
120
|
" with:",
|
|
106
121
|
" php-version: '8.3'",
|
|
107
122
|
" - run: composer install --no-interaction --no-progress",
|
|
@@ -109,12 +124,16 @@ const QUALITY_BY_LANGUAGE = {
|
|
|
109
124
|
" - run: vendor/bin/phpunit",
|
|
110
125
|
]),
|
|
111
126
|
};
|
|
112
|
-
/**
|
|
113
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Stack-aware Quality workflow entry, or null for an unknown language.
|
|
129
|
+
* `lint:false` drops the JS lint step entirely (project has no lint script,
|
|
130
|
+
* KJC-BUG-0137) — mirroring the local hook, which also omits it.
|
|
131
|
+
*/
|
|
132
|
+
export function qualityWorkflowFor(language, pm = "npm", lint = true) {
|
|
114
133
|
const c = PM_COMMANDS[pm] || PM_COMMANDS.npm;
|
|
115
134
|
const body =
|
|
116
135
|
language === "javascript" || language === "typescript"
|
|
117
|
-
? header([...nodeSetupSteps(pm), ` - run: ${c.lint}
|
|
136
|
+
? header([...nodeSetupSteps(pm), ...(lint ? [` - run: ${c.lint}`] : []), ` - run: ${c.test}`])
|
|
118
137
|
: QUALITY_BY_LANGUAGE[language];
|
|
119
138
|
return body ? { file: "kj-quality.yml", blockId: "wf-quality", body } : null;
|
|
120
139
|
}
|
|
@@ -132,7 +151,7 @@ export const SHRINK_BUDGET_WORKFLOW = [
|
|
|
132
151
|
" runs-on: ubuntu-latest",
|
|
133
152
|
' env: { LOC_LIMIT: "200" }',
|
|
134
153
|
" steps:",
|
|
135
|
-
|
|
154
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
136
155
|
" with:",
|
|
137
156
|
" fetch-depth: 0",
|
|
138
157
|
" - name: Net LOC delta within budget",
|
|
@@ -161,7 +180,7 @@ export const packSmokeWorkflow = (pm = "npm") =>
|
|
|
161
180
|
" pack-smoke:",
|
|
162
181
|
" runs-on: ubuntu-latest",
|
|
163
182
|
" steps:",
|
|
164
|
-
|
|
183
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
165
184
|
...nodeSetupSteps(pm),
|
|
166
185
|
" - name: Pack and install the tarball clean",
|
|
167
186
|
" run: |",
|
|
@@ -179,13 +198,13 @@ export const packSmokeWorkflow = (pm = "npm") =>
|
|
|
179
198
|
const MUTATION_TOOLCHAIN = {
|
|
180
199
|
javascript: "node", // resolved per package manager in mutationWorkflowFor
|
|
181
200
|
python: [
|
|
182
|
-
|
|
201
|
+
` - uses: ${PINNED_ACTIONS.setupPython}`,
|
|
183
202
|
" with:",
|
|
184
203
|
" python-version: '3.12'",
|
|
185
204
|
" - run: pip install mutmut pytest",
|
|
186
205
|
],
|
|
187
206
|
php: [
|
|
188
|
-
|
|
207
|
+
` - uses: ${PINNED_ACTIONS.setupPhp}`,
|
|
189
208
|
" with:",
|
|
190
209
|
" php-version: '8.3'",
|
|
191
210
|
" - run: composer install --no-interaction --no-progress",
|
|
@@ -211,10 +230,10 @@ export function mutationWorkflowFor(language, pm = "npm") {
|
|
|
211
230
|
" mutation:",
|
|
212
231
|
" runs-on: ubuntu-latest",
|
|
213
232
|
" steps:",
|
|
214
|
-
|
|
233
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
215
234
|
" with:",
|
|
216
235
|
" fetch-depth: 0",
|
|
217
|
-
|
|
236
|
+
` - uses: ${PINNED_ACTIONS.setupNode}`,
|
|
218
237
|
" with:",
|
|
219
238
|
" node-version: 22",
|
|
220
239
|
...toolchain,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff-clip — KJC-BUG-0134 (issue #1381). A bare [TRUNCATED] marker inside
|
|
3
|
+
* the diff body reads as file content: reviewers blamed whichever file the
|
|
4
|
+
* cut landed in and rejected complete, untouched code (each false positive
|
|
5
|
+
* costs a solomon round). The clip is DECLARED as the pipeline's own, kept
|
|
6
|
+
* OUTSIDE the diff body, cut on a line boundary, and the reviewer is told
|
|
7
|
+
* the diff is partial so it never reasons about the completeness of files
|
|
8
|
+
* it cannot fully see. Shared by every prompt that hands a diff to an AI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DIFF_CLIP_CHARS = 12000;
|
|
12
|
+
|
|
13
|
+
/** @returns {{body: string, note: string|null}} note is null when no clip happened. */
|
|
14
|
+
export function clipDiff(diff, limit = DIFF_CLIP_CHARS) {
|
|
15
|
+
if (!diff) return { body: "", note: null };
|
|
16
|
+
if (diff.length <= limit) return { body: diff, note: null };
|
|
17
|
+
const hard = diff.slice(0, limit);
|
|
18
|
+
const nl = hard.lastIndexOf("\n");
|
|
19
|
+
const body = nl > 0 ? hard.slice(0, nl) : hard;
|
|
20
|
+
const omittedLines = diff.slice(body.length).split("\n").filter(Boolean).length;
|
|
21
|
+
const note = [
|
|
22
|
+
`NOTE FROM THE KJ PIPELINE (not part of the diff): the diff above was clipped by kj at ~${limit} characters — ${omittedLines} more line(s) exist but are NOT shown.`,
|
|
23
|
+
"The cut is kj's, not the author's: no file in the working tree is truncated.",
|
|
24
|
+
"Do not raise issues about incomplete or cut-off content, and do not reason about the completeness of files you cannot fully see. Review only what is visible.",
|
|
25
|
+
].join(" ");
|
|
26
|
+
return { body, note };
|
|
27
|
+
}
|
package/src/prompts/reviewer.js
CHANGED
|
@@ -2,6 +2,8 @@ import { buildRtkInstructions } from "./rtk-snippet.js";
|
|
|
2
2
|
import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
|
|
3
3
|
import { getLanguageInstruction } from "../utils/locale.js";
|
|
4
4
|
import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "./prompt-layout.js";
|
|
5
|
+
import { clipDiff } from "./diff-clip.js";
|
|
6
|
+
import { buildSplitSignal } from "./split-signal.js";
|
|
5
7
|
|
|
6
8
|
const SUBAGENT_PREAMBLE = [
|
|
7
9
|
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
@@ -34,7 +36,9 @@ const SERENA_INSTRUCTIONS = [
|
|
|
34
36
|
* and always different) goes last.
|
|
35
37
|
*/
|
|
36
38
|
export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null }) {
|
|
37
|
-
const
|
|
39
|
+
const { body: clippedDiff, note: clipNote } = clipDiff(diff);
|
|
40
|
+
// KJC-BUG-0138: full diff on purpose — the moved-to side survives clipping.
|
|
41
|
+
const splitSignal = buildSplitSignal(diff);
|
|
38
42
|
|
|
39
43
|
const langInstruction = getLanguageInstruction(language);
|
|
40
44
|
const rtkSnippet = buildRtkInstructions({ rtkAvailable });
|
|
@@ -60,7 +64,9 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
|
|
|
60
64
|
section(`Review rules:\n${reviewRules}`, STABLE),
|
|
61
65
|
section(skillSection, STABLE),
|
|
62
66
|
section(`Task context:\n${task}`, VOLATILE),
|
|
63
|
-
section(`Git diff:\n${
|
|
67
|
+
section(`Git diff:\n${clippedDiff}`, VOLATILE),
|
|
68
|
+
section(clipNote, VOLATILE),
|
|
69
|
+
section(splitSignal, VOLATILE),
|
|
64
70
|
]);
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* split-signal — KJC-BUG-0138 (issue #1364). A file split is not a coverage
|
|
3
|
+
* deletion, but the reviewer doesn't correlate a file losing content with new
|
|
4
|
+
* files gaining it (4/13 pure-refactor PRs falsely rejected). Deterministic
|
|
5
|
+
* pre-analysis, zero LLM: significant removed lines reappearing verbatim among
|
|
6
|
+
* another file's ADDITIONS in the same diff are moves — when ≥ MIN_RATIO of a
|
|
7
|
+
* file's removals move, the reviewer gets the correlation as a pipeline note.
|
|
8
|
+
* Exact `--- a/` / `+++ b/` headers only (BUG-0132: `++content` ≠ header).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MIN_REMOVED = 10; // fewer significant removals ⇒ too small to misread
|
|
12
|
+
const MIN_RATIO = 0.5; // below this, the removal is mostly a real removal
|
|
13
|
+
|
|
14
|
+
// Braces/punctuation reappear by accident — only letter-bearing lines count.
|
|
15
|
+
function significant(text) {
|
|
16
|
+
const t = text.trim();
|
|
17
|
+
return t.length >= 10 && /[a-zA-Z]/.test(t);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @returns {string|null} pipeline note, or null when nothing moved. Feed the
|
|
21
|
+
* PRE-CLIPPING diff: the added side must inform even when clipped away. */
|
|
22
|
+
export function buildSplitSignal(diff) {
|
|
23
|
+
if (!diff) return null;
|
|
24
|
+
const removedByFile = new Map();
|
|
25
|
+
const addedByFile = new Map();
|
|
26
|
+
let src = null;
|
|
27
|
+
let dst = null;
|
|
28
|
+
for (const raw of diff.split("\n")) {
|
|
29
|
+
if (raw.startsWith("--- ")) {
|
|
30
|
+
src = raw.startsWith("--- a/") ? raw.slice(6) : null;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (raw.startsWith("+++ ")) {
|
|
34
|
+
dst = raw.startsWith("+++ b/") ? raw.slice(6) : null;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (raw.startsWith("+") && dst && significant(raw.slice(1))) {
|
|
38
|
+
const counts = addedByFile.get(dst) ?? new Map();
|
|
39
|
+
const t = raw.slice(1).trim();
|
|
40
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
41
|
+
addedByFile.set(dst, counts);
|
|
42
|
+
} else if (raw.startsWith("-") && src && significant(raw.slice(1))) {
|
|
43
|
+
const lines = removedByFile.get(src) ?? [];
|
|
44
|
+
lines.push(raw.slice(1).trim());
|
|
45
|
+
removedByFile.set(src, lines);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const findings = [];
|
|
50
|
+
for (const [file, removed] of removedByFile) {
|
|
51
|
+
if (removed.length < MIN_REMOVED) continue;
|
|
52
|
+
const dests = new Map();
|
|
53
|
+
let moved = 0;
|
|
54
|
+
for (const text of removed) {
|
|
55
|
+
for (const [other, counts] of addedByFile) {
|
|
56
|
+
if (other === file) continue; // in-place edits are not moves
|
|
57
|
+
const n = counts.get(text) ?? 0;
|
|
58
|
+
if (n > 0) {
|
|
59
|
+
counts.set(text, n - 1); // each added line vouches for ONE removal
|
|
60
|
+
dests.set(other, (dests.get(other) ?? 0) + 1);
|
|
61
|
+
moved++;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (moved / removed.length >= MIN_RATIO) {
|
|
67
|
+
const where = [...dests.entries()]
|
|
68
|
+
.sort((a, b) => b[1] - a[1])
|
|
69
|
+
.slice(0, 4)
|
|
70
|
+
.map(([d, n]) => `${d} (${n})`)
|
|
71
|
+
.join(", ");
|
|
72
|
+
findings.push(`- ${file}: ${moved} of ${removed.length} significant removed lines reappear ADDED in ${where}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (findings.length === 0) return null;
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
"NOTE FROM THE KJ PIPELINE (deterministic pre-analysis, not from the author): this diff MOVES content between files:",
|
|
79
|
+
...findings,
|
|
80
|
+
"Moved content is a file split/relocation, not a deletion. Before reporting removed tests or lost coverage, check the ADDED side listed above in this SAME diff. Do not treat moved lines as deleted.",
|
|
81
|
+
].join("\n");
|
|
82
|
+
}
|
|
@@ -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${
|
|
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
|
+
}
|