karajan-code 4.13.0 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/docs/README.es.md +2 -1
  3. package/package.json +3 -3
  4. package/packages/ai-trash/src/cli.js +3 -1
  5. package/packages/hu-board/public/utils/formatters.js +1 -1
  6. package/packages/hu-board/src/cleanup-zombies.js +0 -1
  7. package/packages/hu-board/src/command-runner.js +0 -1
  8. package/packages/hu-board/src/config-yaml.js +1 -2
  9. package/packages/hu-board/src/ephemeral-cleaner.js +0 -2
  10. package/packages/hu-board/src/plan-mutations.js +0 -1
  11. package/packages/hu-board/src/preflight.js +1 -1
  12. package/packages/hu-board/src/routes/api.js +0 -15
  13. package/packages/hu-board/src/server.js +0 -1
  14. package/packages/hu-board/src/sync.js +1 -1
  15. package/packages/hu-board/src/token-store.js +0 -1
  16. package/packages/hu-board/src/zombie-reaper.js +0 -1
  17. package/src/agents/agy-agent.js +62 -0
  18. package/src/agents/claude-agent.js +3 -2
  19. package/src/agents/codex-agent.js +3 -1
  20. package/src/agents/index.js +4 -0
  21. package/src/agents/kimi-agent.js +45 -0
  22. package/src/checks/ai-surface.js +5 -2
  23. package/src/checks/binaries.js +29 -1
  24. package/src/cli/advanced-commands.js +1 -1
  25. package/src/cli/register-pipeline.js +26 -4
  26. package/src/commands/check.js +9 -1
  27. package/src/commands/harden.js +1 -1
  28. package/src/commands/tournament.js +85 -0
  29. package/src/config/overrides.js +8 -1
  30. package/src/harden/config-engine.js +27 -0
  31. package/src/harden/config-templates.js +8 -3
  32. package/src/harden/workflow-engine.js +11 -1
  33. package/src/harden/workflow-templates.js +41 -22
  34. package/src/prompts/diff-clip.js +27 -0
  35. package/src/prompts/reviewer.js +8 -2
  36. package/src/prompts/split-signal.js +82 -0
  37. package/src/review/one-shot-review.js +44 -10
  38. package/src/review/reviewer-fallback.js +125 -0
  39. package/src/roles/reviewer-role.js +9 -8
  40. package/src/tournament/crown.js +88 -0
  41. package/src/tournament/judge.js +151 -0
  42. package/src/tournament/run.js +142 -0
  43. package/src/tournament/scoreboard.js +124 -0
  44. package/src/utils/agent-detect.js +31 -2
  45. package/src/utils/os-detect.js +5 -0
  46. package/src/utils/role-env.js +2 -1
@@ -0,0 +1,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
+ }
@@ -81,7 +81,14 @@ function applyRoleOverrides(out, flags) {
81
81
  // boolean means the flag belongs to the command itself (`kj audit
82
82
  // --security`, KJC-TSK-0695) and must not become a provider.
83
83
  for (const [flag, role] of ROLE_PROVIDER_FLAGS) {
84
- if (typeof flags[flag] === "string" && flags[flag]) out.roles[role].provider = flags[flag];
84
+ if (typeof flags[flag] === "string" && flags[flag]) {
85
+ // A model pin belongs to the provider it was written for — switching
86
+ // provider by flag drops it (KJC-TSK-0729: codex's pinned mini reached
87
+ // agy, which answered with its model list instead of a verdict). An
88
+ // explicit --<role>-model flag re-pins below, after this loop.
89
+ if (out.roles[role].provider && out.roles[role].provider !== flags[flag]) out.roles[role].model = null;
90
+ out.roles[role].provider = flags[flag];
91
+ }
85
92
  }
86
93
  // coder/reviewer also update top-level aliases
87
94
  if (flags.coder) out.coder = flags.coder;
@@ -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
- /** `json:true` ⇒ seed-only (no comment syntax for a marker). */
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
- " - 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,
@@ -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
+ }
@@ -16,6 +16,7 @@ import { parseMaybeJsonString } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
17
  import { saveVerdict } from "./verdict-store.js";
18
18
  import { detectWorkspace } from "./workspace.js";
19
+ import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
19
20
 
20
21
  // Cross-AI preference when the configured reviewer IS the host.
21
22
  const CROSS_ORDER = ["codex", "claude", "gemini", "opencode", "aider"];
@@ -44,6 +45,7 @@ export async function runOneShotReview({
44
45
  hostAgent = detectHostAgent(),
45
46
  createAgentFn = createAgent,
46
47
  detectAgents = detectAvailableAgents,
48
+ candidateStatusFn = candidateStatus,
47
49
  }) {
48
50
  if (!diff || !diff.trim()) {
49
51
  throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
@@ -57,26 +59,58 @@ export async function runOneShotReview({
57
59
  }
58
60
 
59
61
  const { rules } = await resolveReviewProfile({ mode: "standard", projectDir });
60
- const prompt = await buildReviewerPrompt({
61
- task: task || "Review the following diff for correctness, security and maintainability.",
62
- diff, reviewRules: rules, mode: "standard", provider: reviewer, projectDir,
63
- });
62
+ const attempt = async (who, cfg = config) => {
63
+ const prompt = await buildReviewerPrompt({
64
+ task: task || "Review the following diff for correctness, security and maintainability.",
65
+ diff, reviewRules: rules, mode: "standard", provider: who, projectDir,
66
+ });
67
+ logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)`);
68
+ return createAgentFn(who, cfg, logger).reviewTask({ prompt, role: "reviewer" });
69
+ };
70
+
71
+ let activeReviewer = reviewer;
72
+ let result = await attempt(activeReviewer);
73
+
74
+ // KJC-TSK-0730 — quota failover: exhausted quota is not a review failure,
75
+ // it is a provider outage. One retry with an authenticated candidate
76
+ // (loud notice, never silent), or an actionable menu. Never the host:
77
+ // the brain does not review itself.
78
+ if (!result?.ok && isQuotaExhausted(result?.error) && (config?.reviewer_options?.auto_fallback ?? true)) {
79
+ const statuses = await candidateStatusFn();
80
+ const fallback = pickQuotaFallback(statuses, { exclude: [activeReviewer, hostAgent].filter(Boolean) });
81
+ if (fallback) {
82
+ logger?.warn?.(
83
+ `⚠ reviewer ${activeReviewer} sin cuota → usando ${fallback} SOLO en esta invocación. ` +
84
+ `Para fijarlo: roles.reviewer.provider: ${fallback} (avisando, nunca en silencio — KJC-TSK-0730).`
85
+ );
86
+ activeReviewer = fallback;
87
+ // The role's model pin belongs to the exhausted provider (a codex
88
+ // model name means nothing to copilot) — the candidate runs on its
89
+ // own default model.
90
+ result = await attempt(activeReviewer, {
91
+ ...config,
92
+ roles: { ...config?.roles, reviewer: { ...config?.roles?.reviewer, model: null } },
93
+ });
94
+ } else {
95
+ throw new Error(
96
+ `reviewer ${activeReviewer} sin cuota y ningún candidato autenticado con adaptador.\n` +
97
+ formatCandidateMenu(statuses, { exclude: [hostAgent].filter(Boolean) })
98
+ );
99
+ }
100
+ }
64
101
 
65
- logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${reviewer} (cross-AI)`);
66
- const agent = createAgentFn(reviewer, config, logger);
67
- const result = await agent.reviewTask({ prompt, role: "reviewer" });
68
102
  if (!result?.ok) {
69
- throw new Error(`reviewer ${reviewer} failed: ${result?.error || "no output"}`);
103
+ throw new Error(`reviewer ${activeReviewer} failed: ${result?.error || "no output"}`);
70
104
  }
71
105
 
72
106
  const parsed = parseMaybeJsonString(result.output);
73
107
  if (!parsed || typeof parsed.approved !== "boolean") {
74
- throw new Error(`reviewer ${reviewer} returned no parseable verdict`);
108
+ throw new Error(`reviewer ${activeReviewer} returned no parseable verdict`);
75
109
  }
76
110
 
77
111
  return saveVerdict(projectDir, diff, {
78
112
  verdict: parsed.approved ? "approved" : "rejected",
79
- reviewer,
113
+ reviewer: activeReviewer,
80
114
  host: hostAgent || null,
81
115
  // KJC-TSK-0680: where the review ran — makes any isolation claim auditable.
82
116
  workspace: await detectWorkspace(projectDir),