karajan-code 4.28.2 → 4.29.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.28.2",
3
+ "version": "4.29.1",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -112,7 +112,7 @@
112
112
  "express-rate-limit": "^8.5.0",
113
113
  "helmet": "^8.1.0",
114
114
  "js-yaml": "^4.2.0",
115
- "karajan-core": "^1.4.0",
115
+ "karajan-core": "^1.5.0",
116
116
  "karajan-rag": "^1.2.0",
117
117
  "knip": "^6.15.0",
118
118
  "madge": "^8.0.0",
@@ -27,12 +27,28 @@ async function writeJson(file, obj) {
27
27
  await fs.writeFile(file, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
28
28
  }
29
29
 
30
- function resolveKjHome() {
31
- if (process.env.KJ_HOME) return process.env.KJ_HOME;
32
- return path.join(ROOT_DIR, ".karajan");
30
+ const CLAUDE_JSON_PATH = path.join(os.homedir(), ".claude.json");
31
+
32
+ // KJC-BUG-0179 (#1730): a home INSIDE the npm package is the old default
33
+ // this script wrote itself — wiped on every reinstall, never where `kj init`
34
+ // stores kj.config.yml. Such a value is stale, not a user choice.
35
+ const isPackageDirHome = (home) => /node_modules[\\/]karajan-code[\\/]\.karajan[\\/]?$/.test(home);
36
+
37
+ /** The home a previous registration carried, if the user (not this script) set it. */
38
+ function homeFromExistingEntry(entry) {
39
+ const previous = entry?.env?.KARAJAN_HOME || entry?.env?.KJ_HOME;
40
+ return previous && !isPackageDirHome(previous) ? previous : null;
33
41
  }
34
42
 
35
- async function resolveKjHomeFromRegistry() {
43
+ /**
44
+ * Precedence: KARAJAN_HOME → KJ_HOME (deprecated) → the existing entry's home
45
+ * → the instances registry → `~/.karajan` (where the CLI defaults, KJC-BUG-0179).
46
+ */
47
+ async function resolveKjHome(existingEntry) {
48
+ if (process.env.KARAJAN_HOME) return process.env.KARAJAN_HOME;
49
+ if (process.env.KJ_HOME) return process.env.KJ_HOME;
50
+ const previous = homeFromExistingEntry(existingEntry);
51
+ if (previous) return previous;
36
52
  try {
37
53
  const registry = await readJson(REGISTRY_PATH);
38
54
  const names = Object.keys(registry.instances || {});
@@ -43,28 +59,31 @@ async function resolveKjHomeFromRegistry() {
43
59
  } catch {
44
60
  // No registry yet — use default
45
61
  }
46
- return resolveKjHome();
62
+ return path.join(os.homedir(), ".karajan");
47
63
  }
48
64
 
49
- async function setupClaudeMcp(kjHome) {
50
- const claudeJsonPath = path.join(os.homedir(), ".claude.json");
51
- let config = {};
65
+ async function readClaudeConfig() {
52
66
  try {
53
- config = await readJson(claudeJsonPath);
67
+ return await readJson(CLAUDE_JSON_PATH);
54
68
  } catch {
55
- config = {};
69
+ return {};
56
70
  }
71
+ }
57
72
 
73
+ async function setupClaudeMcp(config, kjHome) {
58
74
  config.mcpServers = config.mcpServers || {};
75
+ // Keep every env key the user added to the entry; only the home is ours,
76
+ // and it is written under the current name (KJ_HOME is deprecated).
77
+ const { KJ_HOME: _stale, ...userEnv } = config.mcpServers["karajan-mcp"]?.env || {};
59
78
  config.mcpServers["karajan-mcp"] = {
60
79
  type: "stdio",
61
80
  command: "node",
62
81
  args: [path.join(ROOT_DIR, "src", "mcp", "server.js")],
63
82
  cwd: ROOT_DIR,
64
- env: { KJ_HOME: kjHome }
83
+ env: { ...userEnv, KARAJAN_HOME: kjHome }
65
84
  };
66
85
 
67
- await writeJson(claudeJsonPath, config);
86
+ await writeJson(CLAUDE_JSON_PATH, config);
68
87
  }
69
88
 
70
89
  function upsertCodexMcpBlock(toml, block) {
@@ -96,7 +115,7 @@ async function setupCodexMcp(kjHome) {
96
115
  `args = [${tomlPath(path.join(ROOT_DIR, "src", "mcp", "server.js"))}]`,
97
116
  `cwd = ${tomlPath(ROOT_DIR)}`,
98
117
  '[mcp_servers."karajan-mcp".env]',
99
- `KJ_HOME = ${tomlPath(kjHome)}`
118
+ `KARAJAN_HOME = ${tomlPath(kjHome)}`
100
119
  ].join("\n");
101
120
 
102
121
  const updated = upsertCodexMcpBlock(toml, block);
@@ -105,9 +124,10 @@ async function setupCodexMcp(kjHome) {
105
124
  }
106
125
 
107
126
  async function main() {
108
- const kjHome = await resolveKjHomeFromRegistry();
127
+ const claudeConfig = await readClaudeConfig();
128
+ const kjHome = await resolveKjHome(claudeConfig.mcpServers?.["karajan-mcp"]);
109
129
 
110
- await setupClaudeMcp(kjHome);
130
+ await setupClaudeMcp(claudeConfig, kjHome);
111
131
  await setupCodexMcp(kjHome);
112
132
 
113
133
  console.log("karajan-mcp registered in Claude Code and Codex.");
@@ -39,16 +39,44 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
39
39
 
40
40
  const verdicts = recentVerdicts(projectDir, sample);
41
41
  const stamped = verdicts.filter((v) => v.workspace);
42
+ const sonar = { proved: 0, docsOnly: 0, granted: 0, unproved: 0 };
43
+ for (const v of verdicts) {
44
+ const kind = sonarProof(v);
45
+ if (kind) sonar[kind] += 1;
46
+ }
42
47
 
43
48
  return {
44
49
  commits: { total: subjects.length, withCard: subjects.filter((s) => CARD_REF_RE.test(s)).length },
45
50
  verdicts: { total: verdicts.length, stamped: stamped.length, root: stamped.filter((v) => v.workspace === "root").length },
46
51
  testless: { sampled: blocks.length, offenders },
52
+ sonar,
47
53
  };
48
54
  }
49
55
 
56
+ /**
57
+ * KJC-TSK-0838 (ADR 2026-09-13): what a verdict's sonar block proves —
58
+ * "proved" (ran and covered every source), "docsOnly", "granted" (a human
59
+ * lifted the rule) or "unproved" (an approved verdict for code with no
60
+ * analysis, a skipped pipeline stage, or a source the scan never saw).
61
+ * A verdict without a block predates the ADR and counts as nothing.
62
+ */
63
+ export function sonarProof(v) {
64
+ const s = v?.sonar;
65
+ if (!s || v.verdict !== "approved") return null;
66
+ // A pipeline stamp has no mode: the stage ran, or it did not.
67
+ if (s.source === "pipeline") return s.ran ? "proved" : "unproved";
68
+ // A block without a mode was written before the requirement existed
69
+ // (the block landed one PR before fail-closed): not retroactive either.
70
+ if (!s.mode) return null;
71
+ if (s.mode === "docs-only") return "docsOnly";
72
+ if (s.mode === "granted") return "granted";
73
+ if (s.ran && (s.uncovered || []).length === 0) return "proved";
74
+ return "unproved";
75
+ }
76
+
50
77
  export function formatMethodStats(s) {
51
- return `commits with card ref ${s.commits.withCard}/${s.commits.total} · verdict workspaces root ${s.verdicts.root}/${s.verdicts.stamped || 0} stamped (${s.verdicts.total} total) · source commits without tests ${s.testless.offenders}/${s.testless.sampled}`;
78
+ const sonar = s.sonar ? ` · sonar proof: ${s.sonar.proved} proved, ${s.sonar.docsOnly} docs-only, ${s.sonar.granted} granted, ${s.sonar.unproved} unproved` : "";
79
+ return `commits with card ref ${s.commits.withCard}/${s.commits.total} · verdict workspaces root ${s.verdicts.root}/${s.verdicts.stamped || 0} stamped (${s.verdicts.total} total) · source commits without tests ${s.testless.offenders}/${s.testless.sampled}${sonar}`;
52
80
  }
53
81
 
54
82
  function createMethodCheck() {
@@ -59,6 +87,11 @@ function createMethodCheck() {
59
87
  async detect({ config = {}, projectDir = process.cwd(), run = runCommand } = {}) {
60
88
  const stats = await collectMethodStats({ projectDir, run, sample: config.method_gates?.report_sample || 20 });
61
89
  const detail = formatMethodStats(stats);
90
+ // KJC-TSK-0838: an approved verdict for code without sonar proof is the
91
+ // method in red, not a trend — the gate should have refused it.
92
+ if (stats.sonar.unproved > 0) {
93
+ return { ok: false, severity: "fail", detail: `${stats.sonar.unproved} approved verdict(s) without sonar proof — ${detail}` };
94
+ }
62
95
  const drought = stats.commits.total >= 5 && stats.commits.withCard / stats.commits.total < 0.5;
63
96
  if (drought) {
64
97
  return { ok: false, severity: "warn", detail: `most recent commits carry no card reference — ${detail}` };
@@ -11,11 +11,11 @@ import { isSonarReachable } from "../sonar/manager.js";
11
11
  import { STRATEGY } from "./types.js";
12
12
  import { withDocLink } from "../utils/doc-links.js";
13
13
 
14
- /**
15
- * Whether Sonar is enabled in the active config.
16
- */
17
- function sonarEnabled(config) {
18
- return config.sonarqube?.enabled !== false;
14
+ /** Which config switch turns Sonar off, or null when none does. */
15
+ function disabledSwitch(config) {
16
+ if (config.sonarqube?.enabled === false) return "sonarqube.enabled: false";
17
+ if (config.review_gate?.sonar === false) return "review_gate.sonar: false";
18
+ return null;
19
19
  }
20
20
 
21
21
  function sonarHost(config) {
@@ -33,8 +33,17 @@ function createSonarStatusCheck() {
33
33
  strategy: STRATEGY.NONE,
34
34
  async detect({ config }) {
35
35
  const host = sonarHost(config);
36
- if (!sonarEnabled(config)) {
37
- return { ok: true, severity: "info", detail: "Disabled in config" };
36
+ // KJC-TSK-0838 (ADR 2026-09-13): Sonar switched off is a DEFECT, not a
37
+ // preference — the commit gate rejects code diffs without a sonar
38
+ // proof, so this config only hurts. Only docs-only diffs are exempt.
39
+ const off = disabledSwitch(config);
40
+ if (off) {
41
+ return {
42
+ ok: false,
43
+ severity: "fail",
44
+ detail: `Disabled in config (${off}) — Sonar is mandatory for code; the commit gate rejects code diffs without a sonar proof`,
45
+ fix: withDocLink(`Set sonarqube.enabled: true and drop review_gate.sonar: false; a laptop without Docker runs 'kj sonar start'. Only a human grant (kj policy grant --rule method.sonar.code) lifts the gate.`, "sonar_docker"),
46
+ };
38
47
  }
39
48
  let reachable;
40
49
  try {
@@ -13,6 +13,7 @@ import { runOneShotReview } from "../review/one-shot-review.js";
13
13
  import { runSolomonArbitration } from "../review/solomon-arbitration.js";
14
14
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
15
  import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
+ import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
16
17
  import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
17
18
  import { checkCardFirst } from "../review/card-first.js";
18
19
  import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
@@ -53,6 +54,14 @@ async function rawDiff(range, extraArgs = []) {
53
54
  return res.stdout;
54
55
  }
55
56
 
57
+ // KJC-TSK-0838: a grant is the ONLY way past the sonar requirement, so it is
58
+ // said with who gave it, its scope and until when.
59
+ const formatSonarGrant = (g) => {
60
+ const scope = g.origin === "global" ? " GLOBAL" : "";
61
+ const who = g.who?.git ?? "?";
62
+ return `⚠ sonar requirement lifted by a HUMAN grant [${SONAR_RULE_ID}]${scope} until ${g.expiresAt} — ${who}: ${g.justification || "sin justificación"}`;
63
+ };
64
+
56
65
  // KJC-TSK-0813 (AC3): la exención dice su PROCEDENCIA — un standing global
57
66
  // (concedido para toda la máquina) no pasa por uno del proyecto. El texto
58
67
  // de las de proyecto queda EXACTAMENTE como estaba.
@@ -327,7 +336,25 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
327
336
  return { ok: true, merge: true, reason: "pure merge commit (MERGE_HEAD, empty staged diff)" };
328
337
  }
329
338
  }
330
- const res = await checkVerdict(projectDir, diff);
339
+ let res = await checkVerdict(projectDir, diff);
340
+ // KJC-TSK-0838: a reviewed diff with code enters only if the verdict's
341
+ // sonar block proves the analysis ran and covered every staged source.
342
+ // The headless pipeline stamps its verdict after its own sonar stage and
343
+ // carries no block yet (step 3 of the card) — said, never assumed silent.
344
+ if (res.ok && res.verdict.host === "kj-pipeline") {
345
+ // The pipeline scans the whole project (no per-file proof yet), so its
346
+ // stamp must at least show the stage RAN; no block or a skipped stage
347
+ // is refused like any other unanalysed code.
348
+ const stage = res.verdict.sonar;
349
+ if (!stage?.ran) {
350
+ const why = stage ? `the pipeline's sonar stage did not run: ${stage.reason || "unknown reason"}` : "the pipeline verdict carries no sonar block";
351
+ res = { ok: false, verdict: res.verdict, reason: `Sonar is mandatory for code — ${why}` };
352
+ }
353
+ } else if (res.ok) {
354
+ const req = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: res.verdict.sonar, standingExceptions: std.standing });
355
+ if (!req.ok) res = { ok: false, verdict: res.verdict, reason: req.reason };
356
+ else if (req.mode === "granted") console.log(formatSonarGrant(req.grant));
357
+ }
331
358
  console.log(res.ok
332
359
  ? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
333
360
  : `✗ ${res.reason}`);
@@ -344,6 +371,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
344
371
  // without spending reviewer tokens; the rest travel with the task so
345
372
  // the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
346
373
  let task = flags.task;
374
+ // KJC-TSK-0838: what sonar saw travels INSIDE the verdict, bound to the
375
+ // diff hash — a reviewed diff and a reviewed AND analysed diff must be
376
+ // distinguishable by --check and by the method report.
377
+ let sonarRecord = { ran: false, reason: "--no-sonar: the pre-gate was skipped by flag" };
347
378
  if (flags.sonar !== false) {
348
379
  // KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
349
380
  const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
@@ -356,7 +387,15 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
356
387
  console.log(chosen
357
388
  ? `⚠ sonar pre-gate skipped: ${pre.reason}`
358
389
  : `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
390
+ sonarRecord = { ran: false, reason: pre.reason };
359
391
  } else {
392
+ sonarRecord = {
393
+ ran: true, projectKey: pre.projectKey, covered: pre.covered ?? [], uncovered: pre.uncovered ?? [],
394
+ blocking: pre.blocking.length, advisory: pre.advisory.length,
395
+ };
396
+ if (sonarRecord.uncovered.length > 0) {
397
+ console.log(`⚠ sonar coverage: ${sonarRecord.uncovered.length} staged source(s) were NOT in the analysis (${sonarRecord.uncovered.slice(0, 5).join(", ")}${sonarRecord.uncovered.length > 5 ? "…" : ""}) — the scan did not see them`);
398
+ }
360
399
  const found = [...pre.blocking, ...pre.advisory];
361
400
  if (found.length > 0) {
362
401
  console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
@@ -381,6 +420,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
381
420
  }
382
421
  }
383
422
 
423
+ // KJC-TSK-0838: fail-CLOSED for code. Sonar disabled, down, skipped by
424
+ // flag, or blind to a staged source — the diff does not reach the reviewer.
425
+ // Docs-only diffs pass; a live human grant on the rule is the only escape.
426
+ const sonarReq = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: sonarRecord, standingExceptions: std.standing });
427
+ if (!sonarReq.ok) {
428
+ console.log(`✗ ${sonarReq.reason}`);
429
+ process.exitCode = 1;
430
+ return { verdict: "rejected", reviewer: "sonar", issues: [{ severity: "high", file: undefined, description: sonarReq.reason }] };
431
+ }
432
+ if (sonarReq.mode === "granted") console.log(formatSonarGrant(sonarReq.grant));
433
+ // The verdict says on what grounds the requirement was met (proof, docs-only,
434
+ // human grant) — the method report reads it back.
435
+ sonarRecord.mode = sonarReq.mode;
436
+
384
437
  // MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
385
438
  // SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
386
439
  // el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
@@ -403,7 +456,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
403
456
  }
404
457
  }
405
458
 
406
- const record = await runOneShotReview({ diff, task, config, logger, projectDir });
459
+ const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord });
407
460
  printVerdict(record);
408
461
  process.exitCode = record.verdict === "approved" ? 0 : 1;
409
462
  return record;
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { addCheckpoint } from "../session/store.js";
7
- import { stampStagedVerdict } from "../review/verdict-store.js";
7
+ import { pipelineSonarBlock, stampStagedVerdict } from "../review/verdict-store.js";
8
8
  import {
9
9
  ensureGitRepo,
10
10
  currentBranch,
@@ -277,6 +277,8 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
277
277
  projectDir: config?.projectDir || process.cwd(),
278
278
  reviewer: config?.reviewer || "pipeline-reviewer",
279
279
  summary: `kj run session ${session?.id || ""}: reviewer approved`.trim(),
280
+ // KJC-TSK-0838: the sonar stage result travels with the stamp.
281
+ sonar: pipelineSonarBlock(stageResults?.sonar),
280
282
  }),
281
283
  });
282
284
  committed = commitResult.committed;
@@ -39,6 +39,26 @@ async function runBootstrapGate(server, a) {
39
39
  await ensureBootstrap(projectDir, config);
40
40
  }
41
41
 
42
+ /**
43
+ * KJC-BUG-0175 (#1723): the tool schemas promise "either `task` or
44
+ * `taskFile`", but only kj_run read the file — every direct handler
45
+ * answered "Missing required field: task" to a taskFile-only call. Same
46
+ * resolution as run-handler: read the file (relative to projectDir) into
47
+ * `a.task`, then require it.
48
+ * @returns {Promise<Object|null>} a failPayload when the task is missing or the file unreadable
49
+ */
50
+ async function requireTask(a, server) {
51
+ try {
52
+ await ensureTaskFromFile(a, a.projectDir || (await resolveProjectDir(server, a.projectDir).catch(() => null)));
53
+ } catch (err) {
54
+ return failPayload(`taskFile read failed: ${err.message}`);
55
+ }
56
+ if (!a.task) {
57
+ return failPayload("Missing required field: task (or pass taskFile with a .md path)");
58
+ }
59
+ return null;
60
+ }
61
+
42
62
  function applySessionOverrides(a, roleKeys) {
43
63
  const sessionOvr = getSessionOverrides();
44
64
  for (const key of roleKeys) {
@@ -389,9 +409,8 @@ export async function handleArchitectDirect(a, server, extra) {
389
409
  }
390
410
 
391
411
  export async function handleCode(a, server, extra) {
392
- if (!a.task) {
393
- return failPayload("Missing required field: task");
394
- }
412
+ const missingTask = await requireTask(a, server);
413
+ if (missingTask) return missingTask;
395
414
  await runBootstrapGate(server, a);
396
415
  if (!isPreflightAcked()) {
397
416
  // Auto-acknowledge with defaults for autonomous operation
@@ -404,25 +423,22 @@ export async function handleCode(a, server, extra) {
404
423
  }
405
424
 
406
425
  export async function handleReview(a, server, extra) {
407
- if (!a.task) {
408
- return failPayload("Missing required field: task");
409
- }
426
+ const missingTask = await requireTask(a, server);
427
+ if (missingTask) return missingTask;
410
428
  await runBootstrapGate(server, a);
411
429
  return handleReviewDirect(a, server, extra);
412
430
  }
413
431
 
414
432
  export async function handlePlan(a, server, extra) {
415
- if (!a.task) {
416
- return failPayload("Missing required field: task");
417
- }
433
+ const missingTask = await requireTask(a, server);
434
+ if (missingTask) return missingTask;
418
435
  await runBootstrapGate(server, a);
419
436
  return handlePlanDirect(a, server, extra);
420
437
  }
421
438
 
422
439
  export async function handleDiscover(a, server, extra) {
423
- if (!a.task) {
424
- return failPayload("Missing required field: task");
425
- }
440
+ const missingTask = await requireTask(a, server);
441
+ if (missingTask) return missingTask;
426
442
  const validModes = new Set(["gaps", "momtest", "wendel", "classify", "jtbd"]);
427
443
  if (a.mode && !validModes.has(a.mode)) {
428
444
  return failPayload(`Invalid mode "${a.mode}". Valid values: ${[...validModes].join(", ")}`);
@@ -432,25 +448,22 @@ export async function handleDiscover(a, server, extra) {
432
448
  }
433
449
 
434
450
  export async function handleTriage(a, server, extra) {
435
- if (!a.task) {
436
- return failPayload("Missing required field: task");
437
- }
451
+ const missingTask = await requireTask(a, server);
452
+ if (missingTask) return missingTask;
438
453
  await runBootstrapGate(server, a);
439
454
  return handleTriageDirect(a, server, extra);
440
455
  }
441
456
 
442
457
  export async function handleResearcher(a, server, extra) {
443
- if (!a.task) {
444
- return failPayload("Missing required field: task");
445
- }
458
+ const missingTask = await requireTask(a, server);
459
+ if (missingTask) return missingTask;
446
460
  await runBootstrapGate(server, a);
447
461
  return handleResearcherDirect(a, server, extra);
448
462
  }
449
463
 
450
464
  export async function handleArchitect(a, server, extra) {
451
- if (!a.task) {
452
- return failPayload("Missing required field: task");
453
- }
465
+ const missingTask = await requireTask(a, server);
466
+ if (missingTask) return missingTask;
454
467
  await runBootstrapGate(server, a);
455
468
  return handleArchitectDirect(a, server, extra);
456
469
  }
@@ -1,7 +1,7 @@
1
1
  // KJC-PCS-0049 Step 7 — MCP handlers calling src/rag/* directly. Imports
2
2
  // from src/commands/* are forbidden by the layer-boundaries test
3
3
  // (MCP and CLI are peer layers); we hit the pure rag/* modules instead.
4
- import { countChunks, openVecStore } from "../../rag/vec-store.js";
4
+ import { countChunks, openVecStore, projectSlug } from "../../rag/vec-store.js";
5
5
  import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
6
6
  import { indexProject } from "../../rag/indexer.js";
7
7
  import { query } from "../../rag/retriever.js";
@@ -22,11 +22,17 @@ export async function handleRagQuery(args, server) {
22
22
  const config = await buildConfig({ ...args, projectDir }, "rag-query");
23
23
  const topK = Number(args?.topK) || 5;
24
24
  const scope = args?.scope || "all";
25
+ // KJC-BUG-0177 (#1713): the store is shared by every indexed project,
26
+ // and the MCP tool searched it unfiltered while the CLI (KJC-TSK-0438)
27
+ // scoped to the project's slug. Same rule here: default = slug of
28
+ // projectDir, `project: "all"` disables the filter, `project: <slug>`
29
+ // overrides it.
30
+ const project = args?.project === "all" ? null : (args?.project || projectSlug(projectDir) || null);
25
31
  const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
26
32
  try {
27
- if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope });
28
- const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope });
29
- return responseText({ hits, empty: false, topK, scope });
33
+ if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope, project });
34
+ const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project });
35
+ return responseText({ hits, empty: false, topK, scope, project });
30
36
  } finally { db.close(); }
31
37
  } catch (err) {
32
38
  return failPayload(`kj_rag_query failed: ${err.message}`);
@@ -40,6 +40,7 @@ import {
40
40
  } from "./handlers/management-handlers.js";
41
41
  import { handleHu, handleSkills, handleSuggest } from "./handlers/hu-handlers.js";
42
42
  import { handleRagQuery, handleRagIndex } from "./handlers/rag-handler.js";
43
+ import { withKarajanHome } from "../utils/paths.js";
43
44
 
44
45
  export async function handleToolCall(name, args, server, extra) {
45
46
  const a = asObject(args);
@@ -73,7 +74,16 @@ export async function handleToolCall(name, args, server, extra) {
73
74
  kj_rag_index: (a, server) => handleRagIndex(a, server),
74
75
  }[name];
75
76
  if (handler) {
76
- return handler(a, server, extra);
77
+ // KJC-BUG-0176 (#1722): `kjHome` only reached the env of the `kj`
78
+ // subprocesses spawned by run-kj.js; the direct handlers resolve the
79
+ // home from THIS process (bootstrap gate, config load), so the
80
+ // argument was ignored and the gate failed with "Config file not
81
+ // found". Pin the home to this call's async context instead — no
82
+ // env mutation, so overlapping calls never see each other's home.
83
+ const kjHome = typeof a.kjHome === "string" && a.kjHome.trim() !== "" ? a.kjHome : null;
84
+ return kjHome
85
+ ? withKarajanHome(kjHome, () => handler(a, server, extra))
86
+ : handler(a, server, extra);
77
87
  }
78
88
  return failPayload(`Unknown tool: ${name}`);
79
89
  }
package/src/mcp/tools.js CHANGED
@@ -413,6 +413,7 @@ export const tools = [
413
413
  text: { type: "string", description: "The natural-language query." },
414
414
  topK: { type: "number", description: "Max number of hits to return. Default: 5." },
415
415
  scope: { type: "string", description: "Filter by chunk kind: 'plans' | 'code' | 'onboarding' | 'all' (default)." },
416
+ project: { type: "string", description: "Project slug to search. Defaults to the basename of projectDir (same isolation as `kj rag query`); 'all' searches every indexed project." },
416
417
  projectDir: { type: "string", description: "Absolute path to the project directory" }
417
418
  },
418
419
  required: ["text"]
@@ -43,6 +43,8 @@ export async function pickCrossReviewer({ config, hostAgent, detectAgents = dete
43
43
  */
44
44
  export async function runOneShotReview({
45
45
  diff, task, config, logger, projectDir,
46
+ // KJC-TSK-0838: what the sonar pre-gate saw, persisted with the verdict.
47
+ sonar = null,
46
48
  hostAgent = detectHostAgent(),
47
49
  createAgentFn = createAgent,
48
50
  detectAgents = detectAvailableAgents,
@@ -123,6 +125,7 @@ export async function runOneShotReview({
123
125
  issues: parsed.blocking_issues || [],
124
126
  suggestions: parsed.non_blocking_suggestions || [],
125
127
  summary: parsed.summary || parsed.raw_summary || "",
128
+ ...(sonar ? { sonar } : {}),
126
129
  confidence: parsed.confidence ?? null,
127
130
  });
128
131
  }
@@ -9,9 +9,12 @@
9
9
  * a laptop without Docker still commits.
10
10
  */
11
11
 
12
- import { runSonarScan } from "../sonar/scanner.js";
12
+ import { existsSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { indexedFilesFrom, runSonarScan } from "../sonar/scanner.js";
13
15
  import { getOpenIssues } from "../sonar/api.js";
14
16
  import { acquireToolLock } from "../utils/tool-governor.js";
17
+ import { sourceFilesOf } from "./tests-with-code.js";
15
18
 
16
19
  const BLOCKING_SEVERITIES = new Set(["BLOCKER", "CRITICAL"]);
17
20
 
@@ -59,35 +62,81 @@ export function addedLinesByFile(diffText) {
59
62
  * Every failure path degrades to {available:false, reason} — the pre-gate
60
63
  * never breaks the review, it only refuses to stay silent.
61
64
  */
62
- export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null }) {
65
+ /**
66
+ * KJC-TSK-0838 step 3 (monorepo): each staged file belongs to the nearest
67
+ * directory up from it that owns a sonar-project.properties; the repo root
68
+ * ("") owns the rest. One scan per group, run from that directory.
69
+ * @returns {Map<string, string[]>} repo-relative dir → staged files
70
+ */
71
+ export function groupByScanRoot(files, { root = process.cwd(), exists = existsSync } = {}) {
72
+ const groups = new Map();
73
+ for (const file of files) {
74
+ let owner = "";
75
+ for (let dir = dirname(file); dir && dir !== "."; dir = dirname(dir)) {
76
+ if (exists(join(root, dir, "sonar-project.properties"))) { owner = dir; break; }
77
+ }
78
+ (groups.get(owner) ?? groups.set(owner, []).get(owner)).push(file);
79
+ }
80
+ return groups;
81
+ }
82
+
83
+ export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync }) {
63
84
  if (config?.review_gate?.sonar === false) {
64
85
  return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
65
86
  }
66
87
  let lock = null;
67
88
  try {
68
89
  lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
69
- const scan = await runSonarScan(config);
70
- if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
71
- if (!scan.ok) {
72
- return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
90
+ const groups = groupByScanRoot(stagedFiles, { exists });
91
+ if (groups.size === 0) groups.set("", []);
92
+ const indexed = new Set();
93
+ const issues = [];
94
+ const keys = [];
95
+ let totalProject = 0;
96
+ for (const dir of groups.keys()) {
97
+ const prefix = dir ? `${dir}/` : "";
98
+ // 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) } : {}) });
100
+ if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
101
+ if (!scan.ok) {
102
+ return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
103
+ }
104
+ // Coverage is PROVED by the scanner's own index, never inferred from
105
+ // config — a root-scoped properties file let 4 PRs under packages/radar
106
+ // pass as "0 issues on staged files". No index in the log = no proof.
107
+ const own = indexedFilesFrom(scan.stdout);
108
+ if (own.size === 0) {
109
+ return { available: false, reason: `the scanner log names no indexed file (sonar.verbose) — coverage cannot be proved for ${dir || "the repo root"}` };
110
+ }
111
+ for (const f of own) indexed.add(`${prefix}${f}`);
112
+ keys.push(scan.projectKey);
113
+ // The scan above ALWAYS runs before issues are read (single-flight): the
114
+ // verdict is about the code as it is now, never a stale server analysis
115
+ // (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
116
+ const res = await getOpenIssues(config, scan.projectKey);
117
+ totalProject += res.total ?? (res.issues || []).length;
118
+ // Issue components are package-relative: made repo-relative here.
119
+ for (const i of res.issues || []) issues.push({ ...i, component: `${scan.projectKey}:${prefix}${issueFile(i)}` });
73
120
  }
74
- // The scan above ALWAYS runs before issues are read (single-flight): the
75
- // verdict is about the code as it is now, never a stale server analysis
76
- // (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
77
- const res = await getOpenIssues(config, scan.projectKey);
121
+ const { sources } = sourceFilesOf(config, stagedFiles);
122
+ const covered = sources.filter((f) => indexed.has(f));
123
+ const uncovered = sources.filter((f) => !indexed.has(f));
78
124
  const staged = new Set(stagedFiles);
79
- const onStaged = (res.issues || []).filter((i) => staged.has(issueFile(i)));
125
+ const onStaged = issues.filter((i) => staged.has(issueFile(i)));
80
126
  // KJC-TSK-0795 AC3: with the diff's line map, only issues on lines the PR
81
127
  // ADDS can veto — a 3-line PR must not answer for 30 preexisting issues.
82
128
  // No line, or an untouched line, is the file's TREND: reported, never a block.
83
129
  const isTouched = (i) => !touchedLines || (i.line != null && touchedLines.get(issueFile(i))?.has(Number(i.line)));
84
- const own = onStaged.filter(isTouched);
130
+ const mine = onStaged.filter(isTouched);
85
131
  return {
86
132
  available: true,
87
- blocking: own.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
88
- advisory: own.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
133
+ projectKey: keys.join(","),
134
+ covered,
135
+ uncovered,
136
+ blocking: mine.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
137
+ advisory: mine.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
89
138
  preexisting: touchedLines ? onStaged.filter((i) => !isTouched(i)) : [],
90
- totalProject: res.total ?? (res.issues || []).length,
139
+ totalProject,
91
140
  };
92
141
  } catch (err) {
93
142
  logger?.warn?.(`[sonar-pregate] ${err.message}`);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * KJC-TSK-0838 (ADR "Sonar es un gate de commit no evadible", 2026-09-13) —
3
+ * a diff that touches source files enters only with a sonar block that RAN
4
+ * and COVERED every staged source; docs-only diffs are exempt. The only other
5
+ * way through is a live HUMAN grant on `method.sonar.code` (kj policy grant:
6
+ * identity, expiry, reason, anchored decision log). No env var is read here,
7
+ * on purpose: an escape an agent can set for itself is the hole this closes
8
+ * (KRD-TSK-0023 reached main in 4 code PRs with no real analysis).
9
+ */
10
+ import { sourceFilesOf } from "./tests-with-code.js";
11
+
12
+ export const SONAR_RULE_ID = "method.sonar.code";
13
+
14
+ const GRANT_HINT = `Only a human grant lifts this: kj policy grant --rule ${SONAR_RULE_ID} --until <iso> --reason "<why>"`;
15
+
16
+ function liveGrant(standingExceptions, now) {
17
+ return standingExceptions.find((e) => e.rule_id === SONAR_RULE_ID && new Date(e.expiresAt).getTime() > now.getTime()) || null;
18
+ }
19
+
20
+ /**
21
+ * @returns {{ok: boolean, mode: "docs-only"|"pass"|"granted"|"block", reason?: string, grant?: object, sources?: string[]}}
22
+ */
23
+ export function checkSonarRequirement({ config = {}, stagedFiles = [], sonar, standingExceptions = [], now = new Date() }) {
24
+ const { sources } = sourceFilesOf(config, stagedFiles);
25
+ if (sources.length === 0) return { ok: true, mode: "docs-only" };
26
+
27
+ const grant = liveGrant(standingExceptions, now);
28
+ if (grant) return { ok: true, mode: "granted", grant, sources };
29
+
30
+ const head = `Sonar is mandatory for code (${sources.length} staged source${sources.length === 1 ? "" : "s"})`;
31
+ if (!sonar) {
32
+ return { ok: false, mode: "block", sources, reason: `${head} — the verdict carries no sonar block; run \`kj review --staged\` again. ${GRANT_HINT}` };
33
+ }
34
+ if (!sonar.ran) {
35
+ return { ok: false, mode: "block", sources, reason: `${head} — the analysis did not run: ${sonar.reason || "unknown reason"}. ${GRANT_HINT}` };
36
+ }
37
+ // Recomputed from `covered` against the CURRENT staged sources: the diff
38
+ // hash already pins the file set, so a source missing from the proof is a
39
+ // source the scan never indexed, whatever the block claims.
40
+ const coveredSet = new Set(sonar.covered || []);
41
+ const uncovered = sources.filter((f) => !coveredSet.has(f));
42
+ if (uncovered.length > 0) {
43
+ const list = `${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? "…" : ""}`;
44
+ return { ok: false, mode: "block", sources, reason: `${head} — the scan never indexed ${uncovered.length} of them (${list}): fix sonar-project.properties so they are analysed. ${GRANT_HINT}` };
45
+ }
46
+ return { ok: true, mode: "pass", sources };
47
+ }
@@ -11,17 +11,28 @@
11
11
  const DEFAULT_TEST_PATTERNS = ["/tests/", "/__tests__/", ".test.", ".spec."];
12
12
  const DEFAULT_SOURCE_EXTS = [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"];
13
13
 
14
- export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null, env = process.env }) {
15
- if (env.KJ_ALLOW_NO_TESTS === "1") {
16
- return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
17
- }
14
+ /**
15
+ * The staged files that are SOURCE by the project's own definition: source
16
+ * extension and not a test. Shared by every gate that must tell "code" from
17
+ * "docs" (tests-with-code, the sonar coverage proof of KJC-TSK-0838).
18
+ */
19
+ export function sourceFilesOf(config = {}, files = []) {
18
20
  const patterns = config.development?.test_file_patterns || DEFAULT_TEST_PATTERNS;
19
21
  const exts = config.development?.source_file_extensions || DEFAULT_SOURCE_EXTS;
20
22
  // Staged paths are repo-relative (no leading slash) — normalize so the
21
23
  // "/tests/" style patterns also match the top-level tests directory.
22
24
  const isTest = (f) => patterns.some((p) => `/${f}`.includes(p));
23
- const sources = stagedFiles.filter((f) => !isTest(f) && exts.some((e) => f.endsWith(e)));
24
- const hasTests = stagedFiles.some(isTest);
25
+ return {
26
+ sources: files.filter((f) => !isTest(f) && exts.some((e) => f.endsWith(e))),
27
+ hasTests: files.some(isTest),
28
+ };
29
+ }
30
+
31
+ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null, env = process.env }) {
32
+ if (env.KJ_ALLOW_NO_TESTS === "1") {
33
+ return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
34
+ }
35
+ const { sources, hasTests } = sourceFilesOf(config, stagedFiles);
25
36
 
26
37
  if (sources.length === 0 || hasTests) return { ok: true, mode: "pass" };
27
38
  // KJC-TSK-0795 AC1 (epic KJC-PCS-0082): deleting code adds no behavior to
@@ -96,7 +96,7 @@ export async function loadVerdict(projectDir, hash) {
96
96
  * (KJC-BUG-0115).
97
97
  * @returns {Promise<{stamped: boolean}>}
98
98
  */
99
- export async function stampStagedVerdict({ projectDir, reviewer, summary = "" }) {
99
+ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", sonar = null }) {
100
100
  const dir = projectDir || process.cwd();
101
101
  try {
102
102
  await fs.access(path.join(dir, ".karajan", "review-gate"));
@@ -107,10 +107,30 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "" })
107
107
  if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
108
108
  await saveVerdict(dir, res.stdout, {
109
109
  verdict: "approved", reviewer, host: "kj-pipeline", issues: [], summary,
110
+ // KJC-TSK-0838: what the pipeline's sonar stage saw, so --check can tell
111
+ // a run whose gate ran from one that skipped it.
112
+ ...(sonar ? { sonar } : {}),
110
113
  });
111
114
  return { stamped: true };
112
115
  }
113
116
 
117
+ /**
118
+ * KJC-TSK-0838: the pipeline sonar stage result as a verdict sonar block.
119
+ * SKIPPED (no remote, no key) or absent is `ran:false` WITH the reason — the
120
+ * pre-commit check then refuses code, as it would for any other diff.
121
+ * Coverage lists stay empty: the pipeline scans the whole project, and the
122
+ * per-file proof (verbose index) is not wired into SonarRole yet.
123
+ */
124
+ export function pipelineSonarBlock(stageResult) {
125
+ if (!stageResult) return { ran: false, source: "pipeline", reason: "the pipeline recorded no sonar stage" };
126
+ if (!stageResult.gateStatus || stageResult.gateStatus === "SKIPPED") {
127
+ return { ran: false, source: "pipeline", reason: stageResult.reason || stageResult.error || "the sonar stage did not run" };
128
+ }
129
+ return {
130
+ ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus, covered: [], uncovered: [],
131
+ };
132
+ }
133
+
114
134
  /**
115
135
  * Is there an APPROVED verdict for exactly this diff?
116
136
  * @returns {Promise<{ok: boolean, verdict?: object, reason?: string}>}
@@ -35,7 +35,9 @@ export class CommiterRole extends BaseRole {
35
35
  }
36
36
 
37
37
  async execute(input) {
38
- const { task, commitMessage, push = false, createPr = false } = input || {};
38
+ // KJC-TSK-0838: the caller hands the sonar stage result (pipelineSonarBlock);
39
+ // without it the stamp carries no proof and the commit gate refuses code.
40
+ const { task, commitMessage, push = false, createPr = false, sonar = null } = input || {};
39
41
 
40
42
  const isRepo = await ensureGitRepo();
41
43
  if (!isRepo) {
@@ -65,6 +67,7 @@ export class CommiterRole extends BaseRole {
65
67
  projectDir: this.config?.projectDir || process.cwd(),
66
68
  reviewer: this.config?.reviewer || "pipeline-reviewer",
67
69
  summary: "kj pipeline (commiter role): review passed",
70
+ sonar,
68
71
  }),
69
72
  });
70
73
  const commitHash = await revParse("HEAD");
@@ -37,8 +37,23 @@ export async function pickSonarScanner(scannerConfig = {}, deps = {}) {
37
37
  return { type: "docker" };
38
38
  }
39
39
 
40
+ /**
41
+ * KJC-TSK-0838: the files the scanner ITSELF reports having indexed
42
+ * (`'<path>' indexed with language '<lang>'`, emitted with sonar.verbose).
43
+ * Paths are relative to the scanned base dir. This is the proof of coverage:
44
+ * what the engine saw, not what the config claims it should see.
45
+ * @param {string} scanOutput @returns {Set<string>}
46
+ */
47
+ export function indexedFilesFrom(scanOutput) {
48
+ const files = new Set();
49
+ for (const m of String(scanOutput || "").matchAll(/'([^']+)' indexed with language '/g)) files.add(m[1]);
50
+ return files;
51
+ }
52
+
40
53
  export function buildScannerOpts(projectKey, scanner = {}) {
41
54
  const opts = [`-Dsonar.projectKey=${projectKey}`];
55
+ // KJC-TSK-0838: verbose is what makes the scanner name every indexed file.
56
+ if (scanner.verbose) opts.push("-Dsonar.verbose=true");
42
57
  if (scanner.sources) opts.push(`-Dsonar.sources=${scanner.sources}`);
43
58
  if (scanner.exclusions) opts.push(`-Dsonar.exclusions=${scanner.exclusions}`);
44
59
  if (scanner.test_inclusions) opts.push(`-Dsonar.test.inclusions=${scanner.test_inclusions}`);
@@ -225,18 +240,27 @@ export async function ensureSonarProjectProperties(cwd = process.cwd()) {
225
240
  }
226
241
  }
227
242
 
228
- export async function runSonarScan(config, projectKey = null) {
243
+ export async function runSonarScan(config, projectKey = null, { verbose = false, cwd = null } = {}) {
244
+ const scanCwd = cwd || process.cwd();
245
+ // KJC-TSK-0838 step 3 (monorepo): a nested package scans from its own
246
+ // directory under the key its properties declare — so the proof of
247
+ // coverage is about that package, not the root's `sonar.sources`.
248
+ const repoProps = await ensureSonarProjectProperties(scanCwd);
229
249
  let effectiveProjectKey;
230
- try {
231
- effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
232
- } catch (error) {
233
- return {
234
- ok: false,
235
- projectKey: null,
236
- stdout: "",
237
- stderr: error?.message || String(error),
238
- exitCode: 1
239
- };
250
+ if (cwd && !projectKey && repoProps.declaredKey) {
251
+ effectiveProjectKey = repoProps.declaredKey;
252
+ } else {
253
+ try {
254
+ effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
255
+ } catch (error) {
256
+ return {
257
+ ok: false,
258
+ projectKey: null,
259
+ stdout: "",
260
+ stderr: error?.message || String(error),
261
+ exitCode: 1
262
+ };
263
+ }
240
264
  }
241
265
  const sonarConfig = config?.sonarqube || {};
242
266
  const rawHost = sonarConfig.host || "http://localhost:9000";
@@ -260,7 +284,6 @@ export async function runSonarScan(config, projectKey = null) {
260
284
  }
261
285
  // KJC-BUG-0156: kj's -Dsonar.projectKey stays (scan and query must use the
262
286
  // SAME key), but the repo's properties own the LAYOUT from here on.
263
- const repoProps = await ensureSonarProjectProperties();
264
287
  const token = await resolveSonarTokenWithFallback(config, apiHost);
265
288
  if (!token) {
266
289
  return {
@@ -284,7 +307,8 @@ export async function runSonarScan(config, projectKey = null) {
284
307
  const scannerConfig = respectRepoProperties(
285
308
  normalizeScannerConfig({
286
309
  ...sonarConfig.scanner,
287
- ...coverage.scannerPatch
310
+ ...coverage.scannerPatch,
311
+ ...(verbose ? { verbose: true } : {})
288
312
  }),
289
313
  repoProps
290
314
  );
@@ -310,7 +334,7 @@ export async function runSonarScan(config, projectKey = null) {
310
334
  "run",
311
335
  "--rm",
312
336
  "-v",
313
- `${process.cwd()}:/usr/src`,
337
+ `${scanCwd}:/usr/src`,
314
338
  ...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
315
339
  ...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
316
340
  "-e", "SONAR_HOST_URL",
@@ -320,10 +344,11 @@ export async function runSonarScan(config, projectKey = null) {
320
344
  ];
321
345
  }
322
346
 
323
- const result = await runCommand(cmd, args, { timeout: scannerTimeout, env });
347
+ const result = await runCommand(cmd, args, { timeout: scannerTimeout, env, cwd: scanCwd });
324
348
  return {
325
349
  ok: result.exitCode === 0,
326
350
  projectKey: effectiveProjectKey,
351
+ cwd: scanCwd,
327
352
  scanner: pick.type,
328
353
  note,
329
354
  stdout: result.stdout,
@@ -4,6 +4,7 @@
4
4
  export {
5
5
  resolveHome,
6
6
  getKarajanHome,
7
+ withKarajanHome,
7
8
  __resetKjHomeWarningForTests,
8
9
  getSessionRoot,
9
10
  getSonarComposePath,