karajan-code 4.28.1 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,29 @@ This repo runs under its own environment: every commit to karajan-code carries a
41
41
 
42
42
  ## Install
43
43
 
44
- Tell your agent — in the directory where you want to work:
44
+ Two ways in — pick your comfort level.
45
+
46
+ ### Maggle mode — you never touch the terminal
47
+
48
+ Not a developer, or you just want it running? Paste this into your AI assistant (Claude Code, Codex, Cursor, VS Code):
49
+
50
+ ```text
51
+ Set up Karajan in this project and start it for me: read
52
+ https://karajancode.com/go.md and do everything it says. I'm not a developer —
53
+ don't ask me technical questions, just get it running and tell me when it's ready.
54
+ ```
55
+
56
+ It installs Karajan, prepares the project, and opens a single window with the board and your agent inside — stopping only if a step truly needs you (sudo or an account). Already installed? One command:
57
+
58
+ ```sh
59
+ kj go # or: kj go --window (the agent's terminal embedded in the board)
60
+ ```
61
+
62
+ `kj go` detects your agent (asks which only if you have several), prepares the project silently the first time, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials.
63
+
64
+ ### Developer mode — you stay in control
65
+
66
+ Want your agent to walk each step and stop at every permission? Paste:
45
67
 
46
68
  ```text
47
69
  I want to use Karajan in this project: read https://karajancode.com/start.md
@@ -59,14 +81,6 @@ kj init && kj env install && kj harden && kj review --install-gate
59
81
  git config core.hooksPath .karajan/hooks
60
82
  ```
61
83
 
62
- Already installed and just want to work — even with no computing background? One command:
63
-
64
- ```sh
65
- kj go
66
- ```
67
-
68
- It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials. Prefer everything in ONE browser window? `kj go --window` embeds the agent's real terminal inside the board (loopback-only, single-session token).
69
-
70
84
  Requires git and at least one AI agent CLI — two enables cross-AI review; three enables arbitration. The npm route is `npm install -g @karajan-family/code` (published as `karajan-code` before joining the scope; the legacy name still installs the same versions). All install routes (npm, binaries, brew, Python wrapper) in the [install docs](https://karajancode.com/docs/v4/install/).
71
85
 
72
86
  ## The daily loop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.28.1",
3
+ "version": "4.29.0",
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",
@@ -33,8 +33,25 @@ const pkgName = pkg.name;
33
33
 
34
34
  // Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
35
35
  // runs otherwise) and force a non-interactive, quiet npm.
36
+ // KJC-BUG-0171: the tarball's postinstall registers karajan-mcp in
37
+ // ~/.claude.json / ~/.codex / ~/.karajan pointing at THIS install's path. Every
38
+ // verify install lives in a throwaway prefix, so those paths go stale the moment
39
+ // the temp dir is cleaned — a CONNECTION_CLOSED MCP left in the real user config.
40
+ // Redirect HOME so every global-config write lands in an isolated dir removed
41
+ // with the rest; seed a git identity so any commit still works without ~/.gitconfig.
42
+ const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), "kj-verify-home-"));
36
43
  const { CLAUDECODE: _omit, ...cleanEnv } = process.env;
37
- const childEnv = { ...cleanEnv, npm_config_yes: "true", CI: "1" };
44
+ const childEnv = {
45
+ ...cleanEnv,
46
+ npm_config_yes: "true",
47
+ CI: "1",
48
+ HOME: homeTmp,
49
+ USERPROFILE: homeTmp,
50
+ GIT_AUTHOR_NAME: "kj verify",
51
+ GIT_AUTHOR_EMAIL: "verify@kj.local",
52
+ GIT_COMMITTER_NAME: "kj verify",
53
+ GIT_COMMITTER_EMAIL: "verify@kj.local",
54
+ };
38
55
 
39
56
  function run(cmd, args, opts = {}) {
40
57
  return execFileSync(cmd, args, {
@@ -271,6 +288,7 @@ try {
271
288
  console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
272
289
  } finally {
273
290
  if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
291
+ if (homeTmp && fs.existsSync(homeTmp)) fs.rmSync(homeTmp, { recursive: true, force: true });
274
292
  if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
275
293
  if (gTmp && fs.existsSync(gTmp)) fs.rmSync(gTmp, { recursive: true, force: true });
276
294
  if (pnpmTmp && fs.existsSync(pnpmTmp)) fs.rmSync(pnpmTmp, { recursive: true, force: true });
@@ -8,9 +8,11 @@
8
8
  * ~/.karajan/kj.config.yml (strategy: manual — structural fixes needed)
9
9
  */
10
10
 
11
+ import { existsSync } from "node:fs";
11
12
  import fs from "node:fs/promises";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
14
16
  import { exists } from "../utils/fs.js";
15
17
  import { getConfigPath } from "../config.js";
16
18
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
@@ -132,6 +134,89 @@ function createClaudeConfigCheck() {
132
134
  };
133
135
  }
134
136
 
137
+ /**
138
+ * KJC-BUG-0171: the karajan-mcp server path registered in ~/.claude.json.
139
+ * Returns the args entry that points at the MCP server (…/mcp/server.js), or
140
+ * null when karajan-mcp is not registered.
141
+ */
142
+ export function karajanMcpServerPath(config) {
143
+ const server = config?.mcpServers?.["karajan-mcp"];
144
+ const args = Array.isArray(server?.args) ? server.args : [];
145
+ return args.find((a) => typeof a === "string" && /[\\/]mcp[\\/]server\.js$/.test(a)) ?? null;
146
+ }
147
+
148
+ /**
149
+ * Repair a stale karajan-mcp registration in place: re-point it to this
150
+ * install's server when that server exists, or drop the dead entry when this
151
+ * install has no bundled server (the standalone binary). `serverExists` is
152
+ * injectable for tests. Mutates `config`; returns "repointed" | "removed" | null.
153
+ */
154
+ export function repairKarajanMcpPath(config, correctServerPath, serverExists) {
155
+ const server = config?.mcpServers?.["karajan-mcp"];
156
+ if (!server) return null;
157
+ if (correctServerPath && serverExists(correctServerPath)) {
158
+ server.args = [correctServerPath];
159
+ server.cwd = path.resolve(path.dirname(correctServerPath), "..", "..");
160
+ return "repointed";
161
+ }
162
+ delete config.mcpServers["karajan-mcp"];
163
+ return "removed";
164
+ }
165
+
166
+ /**
167
+ * karajan-mcp registration path validity (~/.claude.json) — KJC-BUG-0171.
168
+ * A verify/temp install used to register the MCP at a throwaway prefix; once
169
+ * cleaned, the path 404s and every session's MCP fails with CONNECTION_CLOSED,
170
+ * and the health check never caught it (it spawns its OWN bundled server).
171
+ */
172
+ function createStaleMcpPathCheck() {
173
+ return {
174
+ name: "agent-config:karajan-mcp-path",
175
+ label: "MCP registration path (~/.claude.json)",
176
+ // KJC-BUG-0172: AUTO, not PROMPT — the runner never remediates a WARN
177
+ // check whose strategy is PROMPT, so the repair was inert under `kj doctor
178
+ // -y`. Re-pointing a dead entry to the current server (or dropping it) is
179
+ // deterministic and reversible on an already-broken entry; `--check-only`
180
+ // stays available to detect without touching the config.
181
+ strategy: STRATEGY.AUTO,
182
+ describe: "Re-point karajan-mcp in ~/.claude.json to this install's server (or drop a dead entry)",
183
+ async detect() {
184
+ const claudeJsonPath = path.join(os.homedir(), ".claude.json");
185
+ let config;
186
+ try {
187
+ config = JSON.parse(await fs.readFile(claudeJsonPath, "utf8"));
188
+ } catch {
189
+ return { ok: true, severity: "info", detail: "Not present or unparseable (skipped)" };
190
+ }
191
+ const registered = karajanMcpServerPath(config);
192
+ if (!registered) return { ok: true, severity: "info", detail: "karajan-mcp not registered (skipped)" };
193
+ if (await exists(registered)) return { ok: true, severity: "info", detail: "karajan-mcp path valid" };
194
+ const correct = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "mcp", "server.js");
195
+ return {
196
+ ok: false,
197
+ severity: "warn",
198
+ detail: `karajan-mcp points at a path that no longer exists: ${registered} — this is the CONNECTION_CLOSED cause`,
199
+ fix: `Re-point karajan-mcp to ${correct} (or remove the entry) in ~/.claude.json`,
200
+ extra: { claudeJsonPath, correct },
201
+ };
202
+ },
203
+ async remediate({ extra }) {
204
+ try {
205
+ const config = JSON.parse(await fs.readFile(extra.claudeJsonPath, "utf8"));
206
+ const action = repairKarajanMcpPath(config, extra.correct, existsSync);
207
+ if (!action) return { fixed: false, detail: "karajan-mcp entry vanished before repair" };
208
+ await fs.writeFile(extra.claudeJsonPath, `${JSON.stringify(config, null, 2)}\n`);
209
+ return {
210
+ fixed: true,
211
+ detail: action === "repointed" ? `Re-pointed karajan-mcp to ${extra.correct}` : "Removed the dead karajan-mcp entry",
212
+ };
213
+ } catch (err) {
214
+ return { fixed: false, detail: `Failed to repair ~/.claude.json: ${err.message}` };
215
+ }
216
+ },
217
+ };
218
+ }
219
+
135
220
  /**
136
221
  * Codex config (~/.codex/config.toml) validity.
137
222
  */
@@ -211,6 +296,7 @@ export function getConfigFileChecks() {
211
296
  createReviewRulesCheck(),
212
297
  createCoderRulesCheck(),
213
298
  createClaudeConfigCheck(),
299
+ createStaleMcpPathCheck(),
214
300
  createCodexConfigCheck(),
215
301
  createKjConfigYamlCheck(),
216
302
  ];
@@ -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 {
@@ -234,12 +234,15 @@ export function registerPipeline(program, { pkgVersion }) {
234
234
  .option("--check", "Verify the recorded verdict matches the staged diff (exit 0/1, hook-friendly)")
235
235
  .option("--range <range>", "Review a git range (e.g. main..HEAD) instead of the staged diff")
236
236
  .option("--install-gate", "Enable the pre-commit review gate for this project (creates .karajan/review-gate)")
237
+ .option("--prune", "Garbage-collect verdicts in .karajan/reviews older than the TTL (they age out as diffs change)")
238
+ .option("--prune-days <n>", "TTL in days for --prune (default: 14)")
239
+ .option("--dry-run", "With --prune: report what would be removed without deleting")
237
240
  .option("--no-sonar", "Skip the deterministic Sonar pre-gate that runs before the cross-AI verdict")
238
241
  .action(async (task, flags) => {
239
242
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
240
243
  // ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
241
244
  // the exact diff so the pre-commit hook can enforce cross-AI review.
242
- if (flags.staged || flags.check || flags.range || flags.installGate) {
245
+ if (flags.staged || flags.check || flags.range || flags.installGate || flags.prune) {
243
246
  await reviewGateCommand({ config, logger, flags: { ...flags, task } });
244
247
  return;
245
248
  }
@@ -8,7 +8,7 @@
8
8
  * no session and no flags → exit 1.
9
9
  */
10
10
 
11
- import { enrollPhone } from "../harden/phone-sign.js";
11
+ import { addSigner, enrollPhone } from "../harden/phone-sign.js";
12
12
  import { readIdentity, writeIdentity } from "../identity/store.js";
13
13
  import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
14
14
  import { compareIdentity } from "../identity/compare.js";
@@ -27,12 +27,15 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
27
27
  // KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
28
28
  if (action === "enroll-phone") {
29
29
  try {
30
+ // KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
31
+ // base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
32
+ addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
30
33
  enrollPhone(flags.publicKeyBase64, { home: deps.home });
31
34
  } catch (err) {
32
35
  log(`kj identity enroll-phone: ${err.message}`);
33
36
  return 1;
34
37
  }
35
- log("Móvil enrolado: la clave pública quedó en ~/.karajan/supervisor-phone.json. A partir de ahora, sellar el supervisor pedirá la firma de tu móvil.");
38
+ log("Móvil enrolado: la clave pública se añadió al padrón .karajan/supervisor-signers.json (commítealo) y a ~/.karajan/supervisor-phone.json. Sellar el supervisor pedirá la firma de un móvil del padrón.");
36
39
  return 0;
37
40
  }
38
41
 
@@ -8,11 +8,12 @@
8
8
  import { existsSync } from "node:fs";
9
9
  import { isAbsolute, join } from "node:path";
10
10
  import { runCommand } from "../utils/process.js";
11
- import { checkVerdict, diffHash } from "../review/verdict-store.js";
11
+ import { checkVerdict, diffHash, pruneVerdicts } from "../review/verdict-store.js";
12
12
  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.
@@ -144,6 +153,21 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
144
153
  return { installed: true };
145
154
  }
146
155
 
156
+ // KJC-BUG-0173: manual GC of the verdict store. Opportunistic pruning runs
157
+ // on every saved verdict; this lets a human sweep on demand, with --dry-run
158
+ // to see what would go without deleting.
159
+ if (flags.prune) {
160
+ const dryRun = Boolean(flags.dryRun || flags.checkOnly);
161
+ const days = Number(flags.pruneDays);
162
+ const res = await pruneVerdicts({ projectDir, dryRun, ...(Number.isFinite(days) && days > 0 ? { maxAgeDays: days } : {}) });
163
+ console.log(
164
+ dryRun
165
+ ? `kj review --prune (dry run): ${res.expired.length} of ${res.scanned} verdict(s) older than ${res.maxAgeDays}d would be removed`
166
+ : `kj review --prune: removed ${res.removed} verdict(s) older than ${res.maxAgeDays}d (${res.scanned - res.removed} kept)`,
167
+ );
168
+ return { pruned: res.removed, dryRun };
169
+ }
170
+
147
171
  const diff = await rawDiff(flags.range);
148
172
 
149
173
  const card = await enforceCardFirst({ config, projectDir });
@@ -312,7 +336,25 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
312
336
  return { ok: true, merge: true, reason: "pure merge commit (MERGE_HEAD, empty staged diff)" };
313
337
  }
314
338
  }
315
- 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
+ }
316
358
  console.log(res.ok
317
359
  ? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
318
360
  : `✗ ${res.reason}`);
@@ -329,6 +371,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
329
371
  // without spending reviewer tokens; the rest travel with the task so
330
372
  // the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
331
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" };
332
378
  if (flags.sonar !== false) {
333
379
  // KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
334
380
  const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
@@ -341,7 +387,15 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
341
387
  console.log(chosen
342
388
  ? `⚠ sonar pre-gate skipped: ${pre.reason}`
343
389
  : `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
390
+ sonarRecord = { ran: false, reason: pre.reason };
344
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
+ }
345
399
  const found = [...pre.blocking, ...pre.advisory];
346
400
  if (found.length > 0) {
347
401
  console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
@@ -366,6 +420,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
366
420
  }
367
421
  }
368
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
+
369
437
  // MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
370
438
  // SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
371
439
  // el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
@@ -388,7 +456,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
388
456
  }
389
457
  }
390
458
 
391
- const record = await runOneShotReview({ diff, task, config, logger, projectDir });
459
+ const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord });
392
460
  printVerdict(record);
393
461
  process.exitCode = record.verdict === "approved" ? 0 : 1;
394
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;
@@ -12,7 +12,7 @@ import { join } from "node:path";
12
12
 
13
13
  import { upsertManagedBlock } from "../utils/managed-markers.js";
14
14
  import { runCommand } from "../utils/process.js";
15
- import { hookBody, PROFILE_HOOKS, SHEBANG } from "./hook-templates.js";
15
+ import { hookBody, PROFILE_HOOKS, SHEBANG, toPortableHooksDir } from "./hook-templates.js";
16
16
 
17
17
  const BLOCK_VERSION = 1;
18
18
  const HOOKS_DIR = join(".karajan", "hooks");
@@ -67,7 +67,10 @@ export async function installHooks({
67
67
  const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
68
68
  const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
69
69
  if (rawGlobal && rawGlobal !== HOOKS_DIR) {
70
- globalHooksDir = rawGlobal.startsWith("~") ? `$HOME${rawGlobal.slice(1)}` : rawGlobal;
70
+ // KJC-BUG-0169: normalize to a machine-independent form HERE (generation,
71
+ // on the human's machine) so the provenance stores the portable dir and CI
72
+ // recomputes the SAME bytes — an absolute home path is not verifiable there.
73
+ globalHooksDir = toPortableHooksDir(rawGlobal);
71
74
  }
72
75
 
73
76
  const absHooksDir = join(projectDir, HOOKS_DIR);
@@ -27,16 +27,24 @@ export const PROFILE_HOOKS = {
27
27
  // hooks dir, silently disabling personal guards. When a previous global dir
28
28
  // is known, every generated hook ends by chaining its namesake there —
29
29
  // guarded, so machines without it are unaffected.
30
+ // KJC-BUG-0161 / 0169 (ADR 0009): a committed hook must not bake a machine's
31
+ // absolute home path — a dir under the home resolves through $HOME so the same
32
+ // generated content is valid on every machine and its provenance stays
33
+ // verifiable IN CI (a different home), with no local filesystem layout leaking
34
+ // into a public repo. Normalization happens ONCE, at generation, on the human's
35
+ // machine; the stored/rendered value is already portable, so a render on any
36
+ // other home (CI) reproduces identical bytes. `home` is injectable for tests.
37
+ export function toPortableHooksDir(dir, home = homedir()) {
38
+ if (!dir) return dir;
39
+ if (dir.startsWith("$HOME")) return dir;
40
+ if (dir.startsWith("~")) return `$HOME${dir.slice(1)}`;
41
+ if (dir === home || dir.startsWith(`${home}/`)) return `$HOME${dir.slice(home.length)}`;
42
+ return dir;
43
+ }
44
+
30
45
  function chainToGlobal(hook, globalHooksDir) {
31
46
  if (!globalHooksDir) return [];
32
- // KJC-BUG-0161 (ADR 0009): a committed hook must not bake a machine's
33
- // absolute home path — resolve through $HOME at runtime so the same
34
- // generated content is valid on every machine and its provenance stays
35
- // verifiable (and no local filesystem layout leaks into a public repo).
36
- const home = homedir();
37
- const dir = globalHooksDir === home || globalHooksDir.startsWith(`${home}/`)
38
- ? `$HOME${globalHooksDir.slice(home.length)}`
39
- : globalHooksDir;
47
+ const dir = toPortableHooksDir(globalHooksDir);
40
48
  return [
41
49
  "# Chain the machine's previous global hook (kj harden keeps it active).",
42
50
  `if [ -x "${dir}/${hook}" ]; then`,
@@ -114,12 +122,19 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
114
122
  "# the same precise pattern (tool MENTIONS stay legal; attribution not).",
115
123
  "# The guard's own definition files are excluded by literal path — they",
116
124
  "# CONTAIN the pattern; excluding a path that does not exist is harmless.",
117
- `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
125
+ "# KJC-BUG-0170: the generated supervisor hooks (.karajan/hooks/pre-commit,",
126
+ "# commit-msg) also CONTAIN the pattern by design — the bootstrap commit",
127
+ "# that versions them must not self-detect in the consumer repo.",
128
+ `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' ':(exclude).karajan/hooks/pre-commit' ':(exclude).karajan/hooks/commit-msg' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
118
129
  " echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
119
130
  "fi",
120
131
  "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
121
132
  "# a staged diff only enters with a recorded cross-AI approved verdict.",
122
- "if [ -f .karajan/review-gate ]; then",
133
+ "# KJC-BUG-0165: the gate is active once the marker is COMMITTED (HEAD),",
134
+ "# not merely present — so the bootstrap commit that INTRODUCES it is",
135
+ "# exempt (no gate can exist before it does), and it cannot be bypassed",
136
+ "# by deleting the working-tree file.",
137
+ "if git cat-file -e HEAD:.karajan/review-gate 2>/dev/null; then",
123
138
  " if ! command -v kj >/dev/null 2>&1; then",
124
139
  " echo 'kj: review gate is enabled but kj is not installed — see karajancode.com/docs/getting-started/installation'; exit 1",
125
140
  " fi",