karajan-code 4.14.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/ai-trash/src/install.js +22 -1
- package/scripts/verify-pack.mjs +28 -6
- package/src/agents/agy-agent.js +1 -0
- package/src/agents/base-agent.js +1 -1
- package/src/agents/claude-agent.js +3 -2
- package/src/agents/codex-agent.js +3 -1
- package/src/agents/kimi-agent.js +1 -0
- package/src/checks/release-check.js +10 -17
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-pipeline.js +26 -4
- package/src/commands/env.js +1 -1
- package/src/commands/harden.js +4 -1
- package/src/commands/rag.js +4 -4
- package/src/commands/review-gate.js +3 -0
- package/src/commands/tournament.js +85 -0
- package/src/config/defaults.js +3 -1
- package/src/environment/board-select.js +2 -1
- package/src/harden/config-engine.js +27 -0
- package/src/harden/config-templates.js +8 -3
- package/src/harden/hook-templates.js +2 -2
- package/src/harden/workflow-engine.js +11 -1
- package/src/harden/workflow-templates.js +41 -22
- package/src/orchestrator/direct-actions.js +12 -1
- package/src/prompts/diff-clip.js +27 -0
- package/src/prompts/reviewer.js +8 -2
- package/src/prompts/split-signal.js +82 -0
- package/src/review/card-first.js +19 -5
- package/src/review/card-verify.js +83 -0
- package/src/roles/reviewer-role.js +9 -8
- package/src/sonar/scanner.js +11 -7
- package/src/tournament/crown.js +88 -0
- package/src/tournament/judge.js +150 -0
- package/src/tournament/run.js +142 -0
- package/src/tournament/scoreboard.js +124 -0
- package/src/utils/update-check.js +2 -1
|
@@ -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,
|
|
@@ -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
|
+
}
|
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
|
+
}
|
package/src/review/card-first.js
CHANGED
|
@@ -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
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
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${
|
|
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 };
|
package/src/sonar/scanner.js
CHANGED
|
@@ -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.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|