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.
@@ -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
- " - uses: actions/checkout@v4",
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
- " - uses: actions/checkout@v4",
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. pnpm passes flags AFTER the script name to the script
61
- // itself, so --if-present must go BEFORE it; yarn has no --if-present at all,
62
- // so lint runs through `npm run` (scripts don't touch the lockfile).
63
- const PM_COMMANDS = {
64
- npm: { setup: [], install: "npm ci", lint: "npm run -s lint --if-present", test: "npm test" },
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 --if-present -s lint",
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 --if-present",
89
+ lint: "npm run -s lint",
75
90
  test: "yarn test",
76
91
  },
77
92
  };
78
93
 
79
94
  const nodeSetupSteps = (pm) => [
80
- " - uses: actions/setup-node@v4",
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
- " - uses: actions/setup-python@v5",
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
- " - uses: actions/setup-go@v5",
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
- " - uses: shivammathur/setup-php@v2",
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
- /** Stack-aware Quality workflow entry, or null for an unknown language. */
113
- export function qualityWorkflowFor(language, pm = "npm") {
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}`, ` - run: ${c.test}`])
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
- " - uses: actions/checkout@v4",
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
- " - uses: actions/checkout@v4",
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
- " - uses: actions/setup-python@v5",
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
- " - uses: shivammathur/setup-php@v2",
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
- " - uses: actions/checkout@v4",
233
+ ` - uses: ${PINNED_ACTIONS.checkout}`,
215
234
  " with:",
216
235
  " fetch-depth: 0",
217
- " - uses: actions/setup-node@v4",
236
+ ` - uses: ${PINNED_ACTIONS.setupNode}`,
218
237
  " with:",
219
238
  " node-version: 22",
220
239
  ...toolchain,
@@ -26,6 +26,17 @@ const ALLOWED_COMMANDS = [
26
26
  "bundle install", "composer install", "dotnet restore"
27
27
  ];
28
28
 
29
+ // KJC-BUG-0099: a dependency install must not be arbitrary code execution —
30
+ // lifecycle hooks (postinstall & friends) run whatever the dependency ships.
31
+ // The allow-list above names the INTENT; execution appends the ecosystem's
32
+ // script-skipping flag. Ecosystems without package hooks run untouched.
33
+ const HARDEN_FLAGS = {
34
+ npm: ["--ignore-scripts"],
35
+ pnpm: ["--ignore-scripts"],
36
+ yarn: ["--ignore-scripts"],
37
+ composer: ["--no-scripts", "--no-plugins"],
38
+ };
39
+
29
40
  /**
30
41
  * True when `target` resolves to a path inside `base` (or is `base` itself).
31
42
  * Uses path.relative instead of a string prefix check: a naive
@@ -64,7 +75,7 @@ async function runCommand({ cmd, cwd }) {
64
75
  // Even though `cmd` is allow-listed, defense-in-depth: switched to
65
76
  // execFileSync with a tokenised arg array so no shell expansion at all.
66
77
  const [program, ...args] = cmd.trim().split(/\s+/);
67
- const output = execFileSync(program, args, {
78
+ const output = execFileSync(program, [...args, ...(HARDEN_FLAGS[program] ?? [])], {
68
79
  cwd: cwd || process.cwd(),
69
80
  encoding: "utf8",
70
81
  stdio: ["pipe", "pipe", "pipe"],
@@ -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
+ }
@@ -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 truncatedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n[TRUNCATED]` : diff;
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${truncatedDiff}`, VOLATILE),
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
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { listPlans, loadPlan } from "../plan/plan-store.js";
10
10
  import { escapeRegExp } from "../utils/escape-regexp.js";
11
+ import { verifyCardAlive } from "./card-verify.js";
11
12
 
12
13
  const LIVE_STATUSES = new Set(["pending", "running", "failed"]);
13
14
  // Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
@@ -44,7 +45,7 @@ async function findLiveCardInBranch(projectDir, branch) {
44
45
  return closedHit;
45
46
  }
46
47
 
47
- export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env }) {
48
+ export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env, deps = {} }) {
48
49
  if (env.KJ_ALLOW_NO_CARD === "1") {
49
50
  return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_CARD=1 (explicit escape hatch)" };
50
51
  }
@@ -73,11 +74,24 @@ export async function checkCardFirst({ config = {}, projectDir = process.cwd(),
73
74
  : { ok: true, mode: "warn", reason };
74
75
  }
75
76
 
76
- // planning-game / external: presence check only — liveness lives in the
77
- // project's own board, out of local reach.
78
- if (CARD_REF_RE.test(branch)) return { ok: true, mode: "pass", ref: branch.match(CARD_REF_RE)[0] };
79
- const effective = policy === "auto" ? "warn" : policy;
77
+ // planning-game / external: presence check, upgraded to LIVENESS against
78
+ // the real tracker when board.verify_cmd declares an adapter
79
+ // (KJC-TSK-0732, issue #1371). Unverifiable degrades honestly — the
80
+ // result names its level so nobody mistakes branch-ref for tracker.
80
81
  const boardName = config.board?.name || backend;
82
+ const effective = policy === "auto" ? "warn" : policy;
83
+ if (CARD_REF_RE.test(branch)) {
84
+ const ref = branch.match(CARD_REF_RE)[0];
85
+ const { verifyFn = verifyCardAlive } = deps;
86
+ const v = await verifyFn({ ref, verifyCmd: config.board?.verify_cmd, projectDir });
87
+ if (v.verified === false) {
88
+ const reason = `branch references ${ref} but ${boardName} reports it "${v.status}" — card-first needs a live card in the tracker (create/reopen it there)`;
89
+ return effective === "block"
90
+ ? { ok: false, mode: "block", reason, level: v.level }
91
+ : { ok: true, mode: "warn", reason, level: v.level };
92
+ }
93
+ return { ok: true, mode: "pass", ref, level: v.level, ...(v.note ? { note: v.note } : {}) };
94
+ }
81
95
  const reason = `no card reference in branch "${branch}" — card-first on ${boardName}: create the card there and name the branch after it (e.g. feat/ABC-123-summary)`;
82
96
  return effective === "block"
83
97
  ? { ok: false, mode: "block", reason }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * card-verify — KJC-TSK-0732 (issue #1371). With a declared tracker,
3
+ * card-first upgrades "the branch names a card" to "the tracker says that
4
+ * card is ALIVE". Transport-agnostic: `board.verify_cmd` is the USER'S
5
+ * adapter (their CLI/MCP wrapper — credentials never enter kj) printing
6
+ * {exists, live, status?} JSON on stdout. Unverifiable NEVER blocks: it
7
+ * degrades to branch-ref level SAYING SO — a gate must not claim a check
8
+ * it didn't make, in either direction. Definitive verdicts are cached
9
+ * briefly; degradations never are, so recovery is immediate.
10
+ */
11
+ import { exec } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { promisify } from "node:util";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { extractFirstJson } from "../utils/json-extract.js";
17
+
18
+ const execAsync = promisify(exec);
19
+ export const VERIFY_TIMEOUT_MS = 4000;
20
+ const CACHE_TTL_MS = 10 * 60 * 1000;
21
+ // The ref lands inside a shell command (the user's adapter template), so it
22
+ // must be inert BY CONSTRUCTION — card-ref alphabet only, anchored (codex
23
+ // catch: same injection class as the tournament coder names).
24
+ const SAFE_REF_RE = /^[a-z0-9][a-z0-9-]{0,63}$/i;
25
+
26
+ const defaultExec = async (cmd, { cwd }) =>
27
+ execAsync(cmd, { cwd, timeout: VERIFY_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
28
+
29
+ function cachePath(projectDir) {
30
+ return path.join(projectDir, ".kj", "cache", "card-verify.json");
31
+ }
32
+
33
+ /** @returns {Promise<{level: "tracker"|"branch-ref", verified: boolean|null, status?: string|null, note?: string}>}
34
+ * verified: true = alive · false = definitively not · null = degraded. */
35
+ export async function verifyCardAlive({ ref, verifyCmd, projectDir = process.cwd(), deps = {} }) {
36
+ const { execFn = defaultExec, now = Date.now } = deps;
37
+ if (!verifyCmd) {
38
+ return {
39
+ level: "branch-ref", verified: null,
40
+ note: "card-first checked the branch reference only — set board.verify_cmd to verify the card against your tracker",
41
+ };
42
+ }
43
+
44
+ if (!SAFE_REF_RE.test(String(ref))) {
45
+ return {
46
+ level: "branch-ref", verified: null,
47
+ note: `tracker verification skipped — ref "${String(ref).slice(0, 30)}" is outside the card-ref alphabet`,
48
+ };
49
+ }
50
+ let cache = {};
51
+ try {
52
+ cache = JSON.parse(fs.readFileSync(cachePath(projectDir), "utf8"));
53
+ } catch { /* no cache yet */ }
54
+ // Keyed by adapter AND ref (reviewer catch): a verdict from one
55
+ // verify_cmd must never answer for a different one.
56
+ const key = `${createHash("sha256").update(verifyCmd).digest("hex").slice(0, 12)}:${ref}`;
57
+ const hit = cache[key];
58
+ if (hit && now() - hit.at < CACHE_TTL_MS) return hit.result;
59
+
60
+ const cmd = verifyCmd.includes("{ref}") ? verifyCmd.replaceAll("{ref}", ref) : `${verifyCmd} ${ref}`;
61
+ try {
62
+ const { stdout } = await execFn(cmd, { cwd: projectDir });
63
+ const parsed = extractFirstJson(String(stdout));
64
+ if (!parsed || typeof parsed.exists !== "boolean") {
65
+ throw new Error("adapter must print JSON {exists, live, status} on stdout");
66
+ }
67
+ const alive = parsed.exists && parsed.live !== false;
68
+ const result = alive
69
+ ? { level: "tracker", verified: true, status: parsed.status ?? null }
70
+ : { level: "tracker", verified: false, status: parsed.status ?? (parsed.exists ? "not-live" : "not-found") };
71
+ try {
72
+ fs.mkdirSync(path.dirname(cachePath(projectDir)), { recursive: true });
73
+ fs.writeFileSync(cachePath(projectDir), JSON.stringify({ ...cache, [key]: { at: now(), result } }));
74
+ } catch { /* cache is best-effort — the verdict stands without it */ }
75
+ return result;
76
+ } catch (err) {
77
+ const why = String(err.message || err).split("\n")[0].slice(0, 120);
78
+ return {
79
+ level: "branch-ref", verified: null,
80
+ note: `tracker verification degraded to branch-ref (${why}) — fix board.verify_cmd or its credentials`,
81
+ };
82
+ }
83
+ }
@@ -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 };
@@ -47,13 +47,17 @@ export function buildScannerOpts(projectKey, scanner = {}) {
47
47
  opts.push(`-Dsonar.javascript.lcov.reportPaths=${scanner.javascript_lcov_report_paths}`);
48
48
  }
49
49
  const rules = scanner.disabled_rules || [];
50
- rules.forEach((rule, i) => {
51
- opts.push(
52
- `-Dsonar.issue.ignore.multicriteria=e${i + 1}`,
53
- `-Dsonar.issue.ignore.multicriteria.e${i + 1}.ruleKey=${rule}`,
54
- `-Dsonar.issue.ignore.multicriteria.e${i + 1}.resourceKey=**/*`
55
- );
56
- });
50
+ if (rules.length > 0) {
51
+ // KJC-BUG-0139: the list property is declared ONCE — one `multicriteria=`
52
+ // per rule was last-wins, silently un-ignoring every rule but the final one.
53
+ opts.push(`-Dsonar.issue.ignore.multicriteria=${rules.map((_, i) => `e${i + 1}`).join(",")}`);
54
+ rules.forEach((rule, i) => {
55
+ opts.push(
56
+ `-Dsonar.issue.ignore.multicriteria.e${i + 1}.ruleKey=${rule}`,
57
+ `-Dsonar.issue.ignore.multicriteria.e${i + 1}.resourceKey=**/*`
58
+ );
59
+ });
60
+ }
57
61
  return opts.join(" ");
58
62
  }
59
63
 
@@ -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
+ }