karajan-code 4.54.0 → 4.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/claude-agent.js +3 -0
- package/src/cli/register-pipeline.js +8 -0
- package/src/commands/harden.js +1 -15
- package/src/commands/plan/generate.js +12 -11
- package/src/commands/rag.js +1 -1
- package/src/commands/review-gate.js +14 -8
- package/src/config/defaults.js +2 -0
- package/src/config/schema.js +6 -0
- package/src/harden/check.js +14 -13
- package/src/harden/config-engine.js +25 -24
- package/src/harden/harden-scope.js +18 -0
- package/src/harden/hook-templates.js +24 -3
- package/src/harden/human-act.js +32 -9
- package/src/harden/sentinel/sentinel-shell.mjs +5 -3
- package/src/harden/sentinel-hooks.js +22 -87
- package/src/hu/coder-routing.js +66 -0
- package/src/hu/model-router.js +18 -20
- package/src/hu/routing-metrics.js +2 -1
- package/src/orchestrator/drivers/iteration-loop.js +2 -0
- package/src/orchestrator/drivers/run-hu-batch.js +4 -1
- package/src/orchestrator/hu-sub-pipeline.js +3 -1
- package/src/orchestrator/stages/coder-stage.js +4 -1
- package/src/prompts/reviewer.js +2 -0
- package/src/rag/indexer.js +2 -2
- package/src/report/model-tally.js +6 -2
- package/src/review/one-shot-review.js +9 -3
- package/src/review/pr-requirements.js +87 -0
- package/src/review/pr-security.js +70 -0
- package/src/review/pr-sonar.js +22 -0
- package/src/review/sonar-pregate.js +15 -4
- package/src/review/third-party-review.js +67 -0
- package/src/roles/agent-role.js +1 -1
- package/src/roles/coder-role.js +8 -2
- package/src/steward/gate.js +31 -0
- package/src/utils/tool-governor.js +31 -16
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1078: Sonar on a third-party PR, when SonarQube is reachable. The
|
|
3
|
+
* own-work pre-gate scans the PR's files and only issues on the lines the PR
|
|
4
|
+
* adds can fail the check; without Sonar the check says "not available" and
|
|
5
|
+
* passes (decision of the user: Sonar if available, never demanded).
|
|
6
|
+
*/
|
|
7
|
+
import { addedLinesByFile, issueFile, runSonarPregate } from "./sonar-pregate.js";
|
|
8
|
+
|
|
9
|
+
export async function sonarCheck({ diff, config, projectDir = process.cwd(), pregate = runSonarPregate }) {
|
|
10
|
+
const touchedLines = addedLinesByFile(diff);
|
|
11
|
+
let res;
|
|
12
|
+
try {
|
|
13
|
+
res = await pregate({ config, stagedFiles: [...touchedLines.keys()], touchedLines, projectDir });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
res = { available: false, reason: err.message };
|
|
16
|
+
}
|
|
17
|
+
if (!res.available) return { id: "sonar", ok: true, detail: `not available: ${res.reason}` };
|
|
18
|
+
const blocking = res.blocking ?? [];
|
|
19
|
+
return blocking.length
|
|
20
|
+
? { id: "sonar", ok: false, detail: blocking.map((i) => `${issueFile(i)}:${i.line} ${i.rule} ${i.severity}`).join("; ") }
|
|
21
|
+
: { id: "sonar", ok: true, detail: `no blocking Sonar issue on the added lines (${(res.advisory ?? []).length} advisory)` };
|
|
22
|
+
}
|
|
@@ -14,6 +14,8 @@ import { dirname, join } from "node:path";
|
|
|
14
14
|
import { indexedFilesFrom, runSonarScan } from "../sonar/scanner.js";
|
|
15
15
|
import { getOpenIssues } from "../sonar/api.js";
|
|
16
16
|
import { acquireToolLock } from "../utils/tool-governor.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_SCAN_SLOTS = 2;
|
|
17
19
|
import { sourceFilesOf } from "./tests-with-code.js";
|
|
18
20
|
|
|
19
21
|
const BLOCKING_SEVERITIES = new Set(["BLOCKER", "CRITICAL"]);
|
|
@@ -80,14 +82,22 @@ export function groupByScanRoot(files, { root = process.cwd(), exists = existsSy
|
|
|
80
82
|
return groups;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
// KJC-TSK-1078: projectDir is where the files live (a third-party PR's clone may not be the cwd).
|
|
86
|
+
export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync, projectDir = process.cwd() }) {
|
|
84
87
|
if (config?.review_gate?.sonar === false) {
|
|
85
88
|
return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
|
|
86
89
|
}
|
|
90
|
+
// KJC-BUG-0368 (#2216): concurrent reviews on one machine share the scanner slots.
|
|
91
|
+
const slots = config?.sonarqube?.max_parallel_scans ?? DEFAULT_SCAN_SLOTS;
|
|
92
|
+
if (!Number.isInteger(slots) || slots < 1) {
|
|
93
|
+
return { available: false, reason: `sonarqube.max_parallel_scans must be a positive integer (got ${JSON.stringify(slots)})` };
|
|
94
|
+
}
|
|
87
95
|
let lock = null;
|
|
88
96
|
try {
|
|
89
|
-
lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
|
|
90
|
-
const groups = groupByScanRoot(stagedFiles, { exists });
|
|
97
|
+
lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000, slots });
|
|
98
|
+
const groups = groupByScanRoot(stagedFiles, { root: projectDir, exists });
|
|
99
|
+
// The root keeps the scanner's own default (cwd) unless the project lives elsewhere.
|
|
100
|
+
const rootCwd = projectDir === process.cwd() ? null : projectDir;
|
|
91
101
|
if (groups.size === 0) groups.set("", []);
|
|
92
102
|
const indexed = new Set();
|
|
93
103
|
const issues = [];
|
|
@@ -96,7 +106,8 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
|
|
|
96
106
|
for (const dir of groups.keys()) {
|
|
97
107
|
const prefix = dir ? `${dir}/` : "";
|
|
98
108
|
// KJC-TSK-0838: verbose, so the scanner names every file it indexed.
|
|
99
|
-
const
|
|
109
|
+
const scanCwd = dir ? join(projectDir, dir) : rootCwd;
|
|
110
|
+
const scan = await runSonarScan(config, null, { verbose: true, ...(scanCwd ? { cwd: scanCwd } : {}) });
|
|
100
111
|
if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
|
|
101
112
|
if (!scan.ok) {
|
|
102
113
|
return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1075 (#2227): review someone else's PR. kj's reviewer, the target
|
|
3
|
+
* repo's rules (.karajan) and the provider the agent picks run over
|
|
4
|
+
* base...head; no own-work gate applies (card, tests, policy, session RAG) and
|
|
5
|
+
* no verdict is stored, so this mode never unlocks a commit (ADR 0015). The
|
|
6
|
+
* own-work gate, reviewGateCommand, stays untouched.
|
|
7
|
+
*/
|
|
8
|
+
import { runCommand } from "../utils/process.js";
|
|
9
|
+
import { resolveRole } from "../config/role-resolver.js";
|
|
10
|
+
import { runOneShotReview } from "./one-shot-review.js";
|
|
11
|
+
import { prRequirementChecks } from "./pr-requirements.js";
|
|
12
|
+
import { prSecurityChecks } from "./pr-security.js";
|
|
13
|
+
import { sonarCheck } from "./pr-sonar.js";
|
|
14
|
+
|
|
15
|
+
const gitDiffOf = async (projectDir, range) => {
|
|
16
|
+
const res = await runCommand("git", ["diff", range], { cwd: projectDir });
|
|
17
|
+
if (res.exitCode !== 0) throw new Error(`git diff ${range} failed: ${(res.stderr || "").trim()}`);
|
|
18
|
+
return res.stdout;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const finding = (i) => ({
|
|
22
|
+
file: i.file ?? null, line: i.line ?? null, severity: i.severity ?? null,
|
|
23
|
+
description: i.description ?? "", suggested_fix: i.suggested_fix ?? null,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** The review as data: verdict, findings, suggestions and the PR-requirement checks. */
|
|
27
|
+
export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck }) {
|
|
28
|
+
const diff = await gitDiff(projectDir, range);
|
|
29
|
+
// KJC-TSK-1076/1077: the PR's requirements and its security, with no tokens; they never stop the review.
|
|
30
|
+
const checks = [...await requirements({ range, projectDir, config }), ...await security({ diff, projectDir, config }), await sonar({ diff, config, projectDir })];
|
|
31
|
+
const record = await run({
|
|
32
|
+
diff, task, config, logger, projectDir,
|
|
33
|
+
reviewerOverride: resolveRole(config, "reviewer").provider || null,
|
|
34
|
+
persist: false,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
mode: "third-party",
|
|
38
|
+
range,
|
|
39
|
+
reviewer: record.reviewer,
|
|
40
|
+
verdict: { approved: record.verdict === "approved", summary: record.summary ?? "" },
|
|
41
|
+
findings: (record.issues ?? []).map(finding),
|
|
42
|
+
suggestions: record.suggestions ?? [],
|
|
43
|
+
checks,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The CLI side: stdout carries exactly one JSON document, logs go to stderr.
|
|
49
|
+
* Exit 0 whatever the PR's verdict (the agent decides what to post); non-zero
|
|
50
|
+
* only when kj itself fails.
|
|
51
|
+
*/
|
|
52
|
+
export async function thirdPartyReviewCommand({ flags, config, out = process.stdout, err = process.stderr, review = thirdPartyReview }) {
|
|
53
|
+
const print = (doc) => out.write(`${JSON.stringify(doc)}\n`);
|
|
54
|
+
if (!flags.range) {
|
|
55
|
+
print({ error: "--third-party reviews a range: pass --range <base>...<head>" });
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
const log = (msg) => err.write(`${msg}\n`);
|
|
59
|
+
const logger = { info: log, warn: log, error: log, debug: () => {} };
|
|
60
|
+
try {
|
|
61
|
+
print(await review({ range: flags.range, config, projectDir: config.projectDir || process.cwd(), logger, task: flags.task ?? null }));
|
|
62
|
+
return 0;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
print({ error: e.message });
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/roles/agent-role.js
CHANGED
|
@@ -121,7 +121,7 @@ export class AgentRole extends BaseRole {
|
|
|
121
121
|
async execute(input) {
|
|
122
122
|
const extracted = this.extractInput(input);
|
|
123
123
|
const provider = this.resolveProvider();
|
|
124
|
-
const agent = this.createAgentInstance(provider);
|
|
124
|
+
const agent = this.createAgentInstance(provider, extracted);
|
|
125
125
|
|
|
126
126
|
const promptResult = await this.buildPrompt(extracted);
|
|
127
127
|
const { prompt, onOutput, stablePrompt, volatilePrompt } = typeof promptResult === "string"
|
package/src/roles/coder-role.js
CHANGED
|
@@ -16,7 +16,11 @@ export class CoderRole extends AgentRole {
|
|
|
16
16
|
return this.config?.roles?.coder?.provider || this.config?.coder || "claude";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
createAgentInstance(provider) {
|
|
19
|
+
createAgentInstance(provider, input = {}) {
|
|
20
|
+
// KJC-TSK-1070 (MR-D2): the HU's route rides on this call only; the shared
|
|
21
|
+
// role keeps its own config (lanes run in parallel).
|
|
22
|
+
const route = input?.route;
|
|
23
|
+
const config = route ? { ...this.config, roles: { ...this.config?.roles, coder: { ...this.config?.roles?.coder, model: route.model, effort: route.effort } } } : this.config;
|
|
20
24
|
// KJC-BUG-0129 (issues #1301/#1303): running INSIDE an agent does not
|
|
21
25
|
// make it "the host executor" — delegating sent the full instruction
|
|
22
26
|
// prompt through elicitInput as a hanging board "question". A CLI
|
|
@@ -29,7 +33,7 @@ export class CoderRole extends AgentRole {
|
|
|
29
33
|
}
|
|
30
34
|
this.logger.info(`Running inside ${provider} — spawning it as a subprocess anyway (set roles.coder.host_delegation: true to delegate to the host)`);
|
|
31
35
|
}
|
|
32
|
-
return this._createAgent(provider,
|
|
36
|
+
return this._createAgent(provider, config, this.logger);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
extractInput(input) {
|
|
@@ -66,6 +70,8 @@ export class CoderRole extends AgentRole {
|
|
|
66
70
|
projectDir: input?.projectDir || null,
|
|
67
71
|
// PAR-H (KJC-TSK-0631): per-call subprocess env (lane slot vars).
|
|
68
72
|
env: input?.env || null,
|
|
73
|
+
// KJC-TSK-1070: the model and effort this HU's coder runs on.
|
|
74
|
+
route: input?.route || null,
|
|
69
75
|
onOutput: input?.onOutput || null
|
|
70
76
|
};
|
|
71
77
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1086: the Steward's block (STW-C, KJC-TSK-0791) at commit too. Only
|
|
3
|
+
* where the project adopted it (method_gates.steward: block), only the hard
|
|
4
|
+
* invariants (vulnerable dependencies, a persistently red main), and never on
|
|
5
|
+
* an invariant whose verdict is unknown. Having declared block, a missing or
|
|
6
|
+
* unreadable report blocks too: the sweep has to run.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const HARD = new Set(["vulnerable-deps", "main-ci"]);
|
|
12
|
+
|
|
13
|
+
/** @returns {{ sweptAt: string, hard: object[], reason: string } | null} */
|
|
14
|
+
export function stewardBlocking({ projectDir, config }) {
|
|
15
|
+
if (config?.method_gates?.steward !== "block") return null;
|
|
16
|
+
const file = join(projectDir, ".karajan", "steward", "report.json");
|
|
17
|
+
let rep;
|
|
18
|
+
try {
|
|
19
|
+
rep = JSON.parse(readFileSync(file, "utf8"));
|
|
20
|
+
} catch (err) {
|
|
21
|
+
// block was adopted explicitly: a missing or unreadable report is never a silent pass.
|
|
22
|
+
return { sweptAt: null, hard: [], reason: `steward cannot read ${file} (${err.message}) — rerun kj steward sweep; while it is unreadable the block stands` };
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(rep?.invariants)) {
|
|
25
|
+
return { sweptAt: rep?.sweptAt ?? null, hard: [], reason: `steward report ${file} has no invariants list — rerun kj steward sweep; while it is malformed the block stands` };
|
|
26
|
+
}
|
|
27
|
+
const hard = rep.invariants.filter((r) => r?.verdict === "broken" && HARD.has(r.id));
|
|
28
|
+
if (hard.length === 0) return null;
|
|
29
|
+
const lines = hard.map((r) => `${r.id}: ${r.evidence || ""} -> ${r.remedy || r.renew || "kj steward sweep"}`);
|
|
30
|
+
return { sweptAt: rep.sweptAt, hard, reason: `steward (sweep ${rep.sweptAt}) blocks: ${lines.join("; ")} — if the work goes on, your user sets method_gates.steward to inform` };
|
|
31
|
+
}
|
|
@@ -28,35 +28,50 @@ function isPidAlive(pid) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
fs.mkdirSync(locksDir, { recursive: true });
|
|
34
|
-
const lockPath = path.join(locksDir, `${tool}.lock`);
|
|
35
|
-
const deadline = Date.now() + timeoutMs;
|
|
31
|
+
/** One slot: {release} when taken, else the live holder; a dead or unreadable holder is stolen. */
|
|
32
|
+
function tryTakeSlot(lockPath) {
|
|
36
33
|
for (;;) {
|
|
37
34
|
try {
|
|
38
35
|
// wx = atomic create-or-fail: the filesystem is the arbiter.
|
|
39
36
|
const fd = fs.openSync(lockPath, "wx");
|
|
40
37
|
fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
41
38
|
fs.closeSync(fd);
|
|
42
|
-
return { release: () => fs.rmSync(lockPath, { force: true }) };
|
|
39
|
+
return { lock: { release: () => fs.rmSync(lockPath, { force: true }) } };
|
|
43
40
|
} catch (err) {
|
|
44
41
|
if (err.code !== "EEXIST") throw err;
|
|
45
42
|
let holder = null;
|
|
46
43
|
try {
|
|
47
44
|
holder = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
48
45
|
} catch { /* unreadable → stale */ }
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
46
|
+
if (holder && Number.isInteger(holder.pid) && isPidAlive(holder.pid)) return { holder };
|
|
47
|
+
fs.rmSync(lockPath, { force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Machine-wide lock for `tool` → {release}; ELOCKTIMEOUT if every live holder
|
|
54
|
+
* outlasts `timeoutMs`. KJC-BUG-0368 (#2216): `slots` lets that many run at
|
|
55
|
+
* once (slot 0 keeps the single-lock path `<tool>.lock`).
|
|
56
|
+
*/
|
|
57
|
+
export async function acquireToolLock(tool, { locksDir = DEFAULT_LOCKS_DIR, timeoutMs = 180_000, pollMs = 500, slots = 1 } = {}) {
|
|
58
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
59
|
+
const paths = Array.from({ length: Math.max(1, slots) }, (_, n) => path.join(locksDir, n === 0 ? `${tool}.lock` : `${tool}.${n}.lock`));
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
for (;;) {
|
|
62
|
+
let holder = null;
|
|
63
|
+
for (const lockPath of paths) {
|
|
64
|
+
const slot = tryTakeSlot(lockPath);
|
|
65
|
+
if (slot.lock) return slot.lock;
|
|
66
|
+
holder ??= slot.holder;
|
|
67
|
+
}
|
|
68
|
+
if (Date.now() >= deadline) {
|
|
69
|
+
const busy = paths.length > 1 ? `, all ${paths.length} slots busy` : "";
|
|
70
|
+
const e = new Error(`${tool} is already running (pid ${holder.pid}, since ${holder.startedAt}${busy}) — gave up waiting after ${timeoutMs}ms`);
|
|
71
|
+
e.code = "ELOCKTIMEOUT";
|
|
72
|
+
throw e;
|
|
59
73
|
}
|
|
74
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
60
75
|
}
|
|
61
76
|
}
|
|
62
77
|
|