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.
- package/README.md +2 -1
- package/docs/README.es.md +2 -1
- package/package.json +3 -3
- package/packages/ai-trash/src/cli.js +3 -1
- package/packages/hu-board/public/utils/formatters.js +1 -1
- package/packages/hu-board/src/cleanup-zombies.js +0 -1
- package/packages/hu-board/src/command-runner.js +0 -1
- package/packages/hu-board/src/config-yaml.js +1 -2
- package/packages/hu-board/src/ephemeral-cleaner.js +0 -2
- package/packages/hu-board/src/plan-mutations.js +0 -1
- package/packages/hu-board/src/preflight.js +1 -1
- package/packages/hu-board/src/routes/api.js +0 -15
- package/packages/hu-board/src/server.js +0 -1
- package/packages/hu-board/src/sync.js +1 -1
- package/packages/hu-board/src/token-store.js +0 -1
- package/packages/hu-board/src/zombie-reaper.js +0 -1
- package/src/agents/agy-agent.js +62 -0
- package/src/agents/claude-agent.js +3 -2
- package/src/agents/codex-agent.js +3 -1
- package/src/agents/index.js +4 -0
- package/src/agents/kimi-agent.js +45 -0
- package/src/checks/ai-surface.js +5 -2
- package/src/checks/binaries.js +29 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-pipeline.js +26 -4
- package/src/commands/check.js +9 -1
- package/src/commands/harden.js +1 -1
- package/src/commands/tournament.js +85 -0
- package/src/config/overrides.js +8 -1
- 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/review/one-shot-review.js +44 -10
- package/src/review/reviewer-fallback.js +125 -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/src/utils/agent-detect.js +31 -2
- package/src/utils/os-detect.js +5 -0
- 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
|
+
}
|
package/src/config/overrides.js
CHANGED
|
@@ -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])
|
|
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
|
-
/**
|
|
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
|
+
}
|
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
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 ${
|
|
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 ${
|
|
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),
|