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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/agents/claude-agent.js +3 -0
  3. package/src/cli/register-pipeline.js +8 -0
  4. package/src/commands/harden.js +1 -15
  5. package/src/commands/plan/generate.js +12 -11
  6. package/src/commands/rag.js +1 -1
  7. package/src/commands/review-gate.js +14 -8
  8. package/src/config/defaults.js +2 -0
  9. package/src/config/schema.js +6 -0
  10. package/src/harden/check.js +14 -13
  11. package/src/harden/config-engine.js +25 -24
  12. package/src/harden/harden-scope.js +18 -0
  13. package/src/harden/hook-templates.js +24 -3
  14. package/src/harden/human-act.js +32 -9
  15. package/src/harden/sentinel/sentinel-shell.mjs +5 -3
  16. package/src/harden/sentinel-hooks.js +22 -87
  17. package/src/hu/coder-routing.js +66 -0
  18. package/src/hu/model-router.js +18 -20
  19. package/src/hu/routing-metrics.js +2 -1
  20. package/src/orchestrator/drivers/iteration-loop.js +2 -0
  21. package/src/orchestrator/drivers/run-hu-batch.js +4 -1
  22. package/src/orchestrator/hu-sub-pipeline.js +3 -1
  23. package/src/orchestrator/stages/coder-stage.js +4 -1
  24. package/src/prompts/reviewer.js +2 -0
  25. package/src/rag/indexer.js +2 -2
  26. package/src/report/model-tally.js +6 -2
  27. package/src/review/one-shot-review.js +9 -3
  28. package/src/review/pr-requirements.js +87 -0
  29. package/src/review/pr-security.js +70 -0
  30. package/src/review/pr-sonar.js +22 -0
  31. package/src/review/sonar-pregate.js +15 -4
  32. package/src/review/third-party-review.js +67 -0
  33. package/src/roles/agent-role.js +1 -1
  34. package/src/roles/coder-role.js +8 -2
  35. package/src/steward/gate.js +31 -0
  36. 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
- export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync }) {
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 scan = await runSonarScan(config, null, { verbose: true, ...(dir ? { cwd: join(process.cwd(), dir) } : {}) });
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
+ }
@@ -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"
@@ -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, this.config, this.logger);
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
- /** Machine-wide lock for `tool` → {release}; ELOCKTIMEOUT if the live holder outlasts `timeoutMs`. */
32
- export async function acquireToolLock(tool, { locksDir = DEFAULT_LOCKS_DIR, timeoutMs = 180_000, pollMs = 500 } = {}) {
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 (!holder || !Number.isInteger(holder.pid) || !isPidAlive(holder.pid)) {
50
- fs.rmSync(lockPath, { force: true });
51
- continue;
52
- }
53
- if (Date.now() >= deadline) {
54
- const e = new Error(`${tool} is already running (pid ${holder.pid}, since ${holder.startedAt}) — gave up waiting after ${timeoutMs}ms`);
55
- e.code = "ELOCKTIMEOUT";
56
- throw e;
57
- }
58
- await new Promise((r) => setTimeout(r, pollMs));
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