karajan-code 4.35.0 → 4.35.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.35.0",
3
+ "version": "4.35.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",
@@ -40,9 +40,16 @@ export function hintForReason(reason, image) {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * KJC-BUG-0228 (issue #1840) — OPT-IN. La imagen por defecto es de un tercero y
45
+ * dejo de existir en el registro publico, asi que el paso venia activo, fallaba
46
+ * con "pull access denied" y se saltaba en cada `kj audit`. kj no puede depender
47
+ * por defecto de algo que otro puede borrar: quien quiera el scorecard lo pide,
48
+ * y quien tenga su propia imagen la declara en `audit.harness.image`.
49
+ */
43
50
  export function normalizeHarnessConfig(h = {}) {
44
51
  return {
45
- enabled: h.enabled !== false,
52
+ enabled: h?.enabled === true,
46
53
  image: h.image || D.image,
47
54
  pullTimeoutMs: pos(h.pull_timeout_ms, D.pullTimeoutMs),
48
55
  assessTimeoutMs: pos(h.assess_timeout_ms, D.assessTimeoutMs),
@@ -20,7 +20,12 @@ const ts = () => new Date().toISOString().replaceAll(/[:.]/g, "-");
20
20
 
21
21
  export async function runHarnessSection({ projectDir, harnessConfig = null, runAssess = runHarnessAssess } = {}) {
22
22
  const cfg = normalizeHarnessConfig(harnessConfig || {});
23
- if (!cfg.enabled) return { ok: true, skipped: true, reason: "disabled" };
23
+ // KJC-BUG-0228: desactivado por defecto, y DICHO. Un paso que desaparece en
24
+ // silencio del informe se lee como un paso aprobado.
25
+ if (!cfg.enabled) {
26
+ const asked = harnessConfig?.enabled === false;
27
+ return { ok: true, skipped: true, reason: asked ? "disabled" : "opt-in" };
28
+ }
24
29
  const res = await runAssess(projectDir, cfg);
25
30
  if (!res.ok) return { ok: false, error: res.error, reason: res.reason, hint: res.hint, image: cfg.image };
26
31
  const outDir = path.join(projectDir, ".karajan", "audit-runs", ts());
@@ -38,7 +43,13 @@ export async function runHarnessSection({ projectDir, harnessConfig = null, runA
38
43
  }
39
44
 
40
45
  export function formatHarnessSection(summary) {
41
- if (!summary || summary.skipped) return "";
46
+ if (!summary) return "";
47
+ // KJC-BUG-0228: cuando no corre porque nadie lo pidio, se dice como pedirlo.
48
+ // Callarlo dejaba un informe que parecia completo sin serlo.
49
+ if (summary.reason === "opt-in") {
50
+ return "## Harness Scorecard\n\n_not run: it is opt-in (`audit.harness.enabled: true`), and it needs a scorecard image you can pull — declare yours in `audit.harness.image`._\n";
51
+ }
52
+ if (summary.skipped) return "";
42
53
  if (!summary.ok) {
43
54
  const reason = summary.reason ? ` (${summary.reason})` : "";
44
55
  const hint = summary.hint ? `\n\n${summary.hint}\n` : "";
@@ -41,7 +41,10 @@ export function createRagCoverageCheck({ openStore = (dim) => openVecStore({ dim
41
41
  const sample = gaps.slice(0, 3).join(", ") + (gaps.length > 3 ? ", …" : "");
42
42
  return { ok: false, severity: "fail", detail: `${c.missing.length} source(s) outside the RAG index, ${c.stale.length} stale (${sample}) — ${fix}` };
43
43
  }
44
- return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}` };
44
+ // KJC-BUG-0221: lo que kj genera no se exige indexado, pero se dice —
45
+ // una exencion que no se ve es indistinguible de un gate que dejo de mirar.
46
+ const generated = c.generated?.length ? `, ${c.generated.length} generated by kj (not required)` : "";
47
+ return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}${generated}` };
45
48
  } finally {
46
49
  db.close();
47
50
  }
@@ -92,8 +92,14 @@ export async function runChecks(checks, ctx, options = {}) {
92
92
  entries.map(async (entry) => {
93
93
  if (entry.status === STATUS.SKIPPED) return;
94
94
  const start = Date.now();
95
+ // Per-check override: checks that spawn slow tools (e.g. `npx <pkg>` cold
96
+ // start) declare detectTimeoutMs so the blanket pipeline timeout doesn't
97
+ // kill them. Falls back to the pipeline-wide timeoutMs.
98
+ const detectTimeoutMs = Number.isFinite(entry.check.detectTimeoutMs)
99
+ ? entry.check.detectTimeoutMs
100
+ : timeoutMs;
95
101
  try {
96
- const result = await withTimeout(entry.check.detect({ config, signal }), timeoutMs, entry.check.name);
102
+ const result = await withTimeout(entry.check.detect({ config, signal }), detectTimeoutMs, entry.check.name);
97
103
  entry.detectResult = result;
98
104
  entry.runMs = Date.now() - start;
99
105
  entry.status = resolveStatusFromDetect(entry.check, result);
@@ -112,9 +118,21 @@ export async function runChecks(checks, ctx, options = {}) {
112
118
  } catch (err) {
113
119
  entry.runMs = Date.now() - start;
114
120
  if (err instanceof TimeoutError) {
115
- entry.detectResult = { ok: false, severity: "warn", detail: `Timeout after ${timeoutMs}ms` };
116
- entry.status = STATUS.TIMEOUT;
117
- entry.detail = entry.detectResult.detail;
121
+ entry.detectResult = { ok: false, severity: "warn", detail: `Timeout after ${detectTimeoutMs}ms` };
122
+ // A timeout on a degradable (optional) check must not block the
123
+ // pipeline: degrade to WARN and disable its feature flags, same as
124
+ // a regular failure (KJC-BUG-0049 semantics).
125
+ if (entry.check.degradable) {
126
+ for (const dotPath of entry.check.degradable.disables || []) {
127
+ setDotPath(overrides, dotPath, false);
128
+ }
129
+ entry.status = STATUS.WARN;
130
+ entry.detail = `${entry.detectResult.detail} → ${entry.check.degradable.warn}`;
131
+ entry.degraded = true;
132
+ } else {
133
+ entry.status = STATUS.TIMEOUT;
134
+ entry.detail = entry.detectResult.detail;
135
+ }
118
136
  } else {
119
137
  entry.detectResult = { ok: false, severity: "fail", detail: `Detection error: ${err.message}` };
120
138
  entry.status = STATUS.FAIL;
@@ -172,7 +190,11 @@ export async function runChecks(checks, ctx, options = {}) {
172
190
  entries.filter((e) => e.remediated).map(async (entry) => {
173
191
  const start = Date.now();
174
192
  try {
175
- const result = await withTimeout(entry.check.detect({ config: verifyConfig, signal }), timeoutMs, `${entry.check.name}:reverify`);
193
+ const result = await withTimeout(
194
+ entry.check.detect({ config: verifyConfig, signal }),
195
+ Number.isFinite(entry.check.detectTimeoutMs) ? entry.check.detectTimeoutMs : timeoutMs,
196
+ `${entry.check.name}:reverify`
197
+ );
176
198
  entry.runMs += Date.now() - start;
177
199
  if (!result.ok) {
178
200
  entry.status = STATUS.FAIL;
@@ -23,6 +23,16 @@ export function createOpenSkillsCheck() {
23
23
  strategy: STRATEGY.PROMPT,
24
24
  applies: skillsEnabled,
25
25
  describe: "Install OpenSkills globally: npm install -g openskills",
26
+ // Detection shells out to `npx openskills --version`; a cold npx cache
27
+ // downloads the package first (~8s observed), well above the pipeline's
28
+ // 3s blanket timeout. Must exceed openskills-client's internal 15s timeout.
29
+ detectTimeoutMs: 20_000,
30
+ // Optional feature: even if detection times out, degrade instead of
31
+ // blocking the whole preflight (skill injection is skipped for the run).
32
+ degradable: {
33
+ disables: ["skills.enabled"],
34
+ warn: "skill auto-install disabled for this run",
35
+ },
26
36
  async detect() {
27
37
  const available = await isOpenSkillsAvailable();
28
38
  if (available) {
@@ -59,7 +59,11 @@
59
59
  * @property {DegradableSpec} [degradable] - KJC-BUG-0049: si está definido y el check falla, en lugar de abortar
60
60
  * el preflight, se desactivan los flags listados en `disables` y se emite
61
61
  * WARN con `warn`. Útil para features opcionales (auto_pr, auto_push,
62
- * sonar advisory, etc.) que no son blockers absolutos.
62
+ * sonar advisory, etc.) que no son blockers absolutos. También aplica
63
+ * cuando el detect agota su timeout (timeout → WARN, no TIMEOUT).
64
+ * @property {number} [detectTimeoutMs] - per-check detect timeout override. Use for checks that spawn slow
65
+ * tools (e.g. `npx <pkg>` cold start) where the pipeline-wide
66
+ * timeoutMs would kill an otherwise healthy detect.
63
67
  */
64
68
 
65
69
  /**
@@ -386,8 +386,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
386
386
  console.log(`⚠ rag [${RAG_RULE_ID}]: the pipeline verdict carries no session ledger — the headless pipeline does not consult the RAG yet; sealed as a warn`);
387
387
  if (!warnIds.includes(RAG_RULE_ID)) warnIds.push(RAG_RULE_ID);
388
388
  } else if (res.ok) {
389
- const { harness, verified, mismatched } = readRagLedger(projectDir);
390
- const rreq = checkRagVerdict({ config, stagedFiles: changedFiles, rag: res.verdict.rag, harness, verified, mismatched, standingExceptions: std.standing });
389
+ const { harness, verified, mismatched, drift } = readRagLedger(projectDir);
390
+ const rreq = checkRagVerdict({ config, stagedFiles: changedFiles, rag: res.verdict.rag, harness, verified, mismatched, drift, standingExceptions: std.standing });
391
391
  if (!rreq.ok) res = { ok: false, verdict: res.verdict, reason: rreq.reason };
392
392
  else if (rreq.mode === "granted" && rreq.grant) console.log(formatRagGrant(rreq.grant));
393
393
  }
@@ -118,6 +118,17 @@ export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style:
118
118
  export const PHPSTAN_BODY = ["parameters:", " level: 6", " paths:", " - src", " - tests"].join("\n");
119
119
  export const PHP_CONFIGS = [{ file: "phpstan.neon", blockId: "phpstan", style: "hash", body: PHPSTAN_BODY }];
120
120
 
121
+
122
+ /**
123
+ * KJC-BUG-0221 — los nombres que kj ESCRIBE en el proyecto. Lo consume la
124
+ * cobertura del RAG para no denunciar como drift un fichero que kj acaba de
125
+ * generar. Se deriva de las tablas de arriba: anadir un config aqui no obliga
126
+ * a acordarse de una segunda lista en otro modulo.
127
+ */
128
+ export const GENERATED_CONFIG_FILES = new Set(
129
+ [...UNIVERSAL_CONFIGS, ...JS_CONFIGS, ...PY_CONFIGS, ...GO_CONFIGS, ...PHP_CONFIGS].map((c) => c.file),
130
+ );
131
+
121
132
  /** Lint/format configs by detected language. Unknown ⇒ universal only. */
122
133
  export const CONFIGS_BY_LANGUAGE = {
123
134
  javascript: JS_CONFIGS,
@@ -9,6 +9,7 @@
9
9
  * supports tool hooks.
10
10
  */
11
11
 
12
+ import { execFileSync } from "node:child_process";
12
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
14
  import { join } from "node:path";
14
15
 
@@ -18,7 +19,11 @@ const SCRIPT_BODY = `#!/usr/bin/env node
18
19
  // so a gate bug never bricks the session.
19
20
  import console from "node:console";
20
21
  import process from "node:process";
21
- import { existsSync } from "node:fs";
22
+ import { existsSync, realpathSync } from "node:fs";
23
+ import { dirname, join, relative, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ // El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
26
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
22
27
  let raw = "";
23
28
  process.stdin.on("data", (d) => { raw += d; });
24
29
  process.stdin.on("end", () => {
@@ -30,10 +35,75 @@ process.stdin.on("end", () => {
30
35
  process.exit(2);
31
36
  }
32
37
  }
33
- if (tool === "Bash" && process.env.KJ_ALLOW_REWRITE !== "1") {
38
+ if (tool === "Bash") {
34
39
  const cmd = String(input.command || "");
40
+ // KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
41
+ // resto de guardias. Mirando solo process.env el agente no tenia salida:
42
+ // un export dentro del comando jamas llega a este proceso.
43
+ // Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
44
+ // aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
45
+ // un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
46
+ const toks = cmd.trim().split(/\\s+/);
47
+ let i = 0;
48
+ let named = false;
49
+ while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
50
+ if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
51
+ i += 1;
52
+ }
53
+ // Una asignacion delante solo alcanza a SU comando, asi que el escape del
54
+ // comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
55
+ // o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
56
+ // que va entre comillas no encadena nada, asi que no cuenta.
57
+ // Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
58
+ // hereda la asignacion (catch de la review). Mismo criterio que el resto
59
+ // de guardias del Sentinel.
60
+ // Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
61
+ // encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
62
+ const noSingle = cmd.replaceAll(/'[^']*'/g, "");
63
+ const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
64
+ const bare = noSingle.replaceAll(/"[^"]*"/g, "");
65
+ const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
66
+ const cmdEscape = named && simple && i < toks.length;
67
+ const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
68
+ // Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
69
+ if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
35
70
  const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
36
- if (/json\\.dumps?\\s*\\(/.test(cmd) && writes) {
71
+ // KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
72
+ // temporal del scratchpad no es asunto de este guard, y bloquearlo
73
+ // ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
74
+ const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
75
+ // Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
76
+ // fuera del arbol. Una relativa depende del cwd del shell, que este hook
77
+ // no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
78
+ // dentro del repo (catch de la review), asi que se sigue vigilando.
79
+ // Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
80
+ // (catch de la review). Se resuelve el padre real antes de clasificar, y
81
+ // si no se puede resolver, se vigila.
82
+ // Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
83
+ // <repo>/..config.json esta dentro (catch de la review).
84
+ const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
85
+ const outsideForSure = (t) => {
86
+ if (!t.startsWith("/")) return false;
87
+ const abs = resolve(t);
88
+ try {
89
+ const root = realpathSync(ROOT);
90
+ // El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
91
+ // asi que se resuelve entero cuando existe; si aun no existe, manda su
92
+ // directorio real.
93
+ if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
94
+ const parts = abs.split("/");
95
+ const name = parts.pop();
96
+ return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
97
+ } catch {
98
+ return false;
99
+ }
100
+ };
101
+ // Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
102
+ // deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
103
+ // de la review). Ante la duda se vigila.
104
+ const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
105
+ const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
106
+ if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
37
107
  console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
38
108
  process.exit(2);
39
109
  }
@@ -100,8 +170,27 @@ export function mergeClaudeHooks({ projectDir, logger = console, entries }) {
100
170
  return { wired: true };
101
171
  }
102
172
 
173
+ /**
174
+ * La raiz del proyecto: el toplevel de git, o el directorio dado si no hay git.
175
+ * Vive aqui y no se importa de sentinel-hooks porque ese modulo importa de
176
+ * este: el ciclo rompe la carga.
177
+ */
178
+ function resolveProjectRoot(dir) {
179
+ try {
180
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || dir;
181
+ } catch {
182
+ return dir;
183
+ }
184
+ }
185
+
103
186
  /** Write the script and merge the PreToolUse entries into .claude/settings.json. */
104
187
  export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
188
+ // KJC-BUG-0223: el harness es del proyecto y vive en su raiz. Instalado desde
189
+ // un subdirectorio sembraba ahi un segundo `.karajan/harness` que no gobierna
190
+ // nada, y el gate seguia mirando otro sitio.
191
+ const root = resolveProjectRoot(projectDir);
192
+ if (root !== projectDir) logger?.info?.(`kj harden: el harness es del proyecto, se instala en su raíz (${root})`);
193
+ projectDir = root;
105
194
  const scriptAbs = writeHarnessScript(projectDir, "pretooluse.mjs", SCRIPT_BODY);
106
195
  const { wired } = mergeClaudeHooks({
107
196
  projectDir,
@@ -1198,7 +1198,11 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1198
1198
  ok: true,
1199
1199
  mismatched: [],
1200
1200
  drift,
1201
- reason: "el harness instalado está sellado y las plantillas de este árbol van por delante (kj harden lo pone al día) — no es manipulación",
1201
+ // KJC-BUG-0222: `kj harden` NO pone al dia un guard sellado, lo deja en paz
1202
+ // a proposito (KJC-BUG-0193), asi que mandarlo era un bucle: ejecutas,
1203
+ // sale "unchanged" y el gate repite lo mismo. El que resuelve ESTE estado
1204
+ // es `kj harden --commit`, y es acto del humano.
1205
+ reason: "el harness instalado está sellado y las plantillas de este árbol van por delante — no es manipulación, y `kj harden` lo deja como está a propósito: quien lo avanza es el humano con `kj harden --commit`",
1202
1206
  };
1203
1207
  }
1204
1208
 
@@ -1214,6 +1218,13 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1214
1218
  * guard is still the right move.
1215
1219
  */
1216
1220
  export function installSentinelHooks({ projectDir = process.cwd(), logger = console, human = false, gitShowFn = null } = {}) {
1221
+ // KJC-BUG-0223: instalado desde un subdirectorio, esto sembraba un segundo
1222
+ // `.karajan/harness` ahi, y el gate (que mira otro sitio) pasaba a verlo. El
1223
+ // ritual quedaba "arreglado" y el arbol se llenaba de arneses huerfanos que
1224
+ // no gobiernan nada. El harness es del proyecto: va a su raiz.
1225
+ const root = resolveSentinelRoot(projectDir);
1226
+ if (root !== projectDir) logger?.info?.(`kj harden: el harness es del proyecto, se instala en su raíz (${root})`);
1227
+ projectDir = root;
1217
1228
  const sealed = human ? new Map() : sealedByPath(projectDir, gitShowFn);
1218
1229
  const deferred = [];
1219
1230
  const keepsItsSeal = (name) => {
@@ -322,7 +322,7 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
322
322
  // Hard fail if blocking checks failed (SonarQube enabled but not available)
323
323
  if (!preflightResult.ok) {
324
324
  const errorLines = (preflightResult.errors || [])
325
- .map(e => ` - ${e.message}\n Fix: ${e.fix}`)
325
+ .map(e => ` - ${e.message}${e.fix ? `\n Fix: ${e.fix}` : ""}`)
326
326
  .join("\n");
327
327
  throw new Error(
328
328
  `Preflight FAILED — environment changed during session. Fix the issues and retry:\n${errorLines}`
@@ -10,8 +10,27 @@
10
10
  import { execa } from "execa";
11
11
  import { extname, isAbsolute, normalize, relative } from "node:path";
12
12
  import { detectAdaptersForProject, buildMatchers } from "../lang/registry.js";
13
+ import { GENERATED_CONFIG_FILES } from "../harden/config-templates.js";
13
14
  import { getLastIndexedCommit, projectSlug } from "./vec-store.js";
14
15
 
16
+
17
+ /**
18
+ * KJC-BUG-0221 — `kj harden` instala commitlint.config.js y eslint.config.js, y
19
+ * la cobertura los exigia indexados, asi que `kj check` denunciaba como drift
20
+ * dos ficheros que kj mismo acababa de escribir: la herramienta creando el
21
+ * fichero y quejandose de el. El criterio es el que ya usa el presupuesto de
22
+ * LOC (src/review/loc-budget.js): lo generado no cuenta, porque nadie lo
23
+ * escribio, y el RAG existe para responder sobre el codigo del equipo.
24
+ *
25
+ * La lista sale de la propia tabla de plantillas de harden, para que anadir un
26
+ * config no obligue a acordarse de esta otra lista.
27
+ */
28
+ export function isGeneratedByHarden(rel) {
29
+ if (!rel) return false;
30
+ const base = rel.split("/").pop();
31
+ return GENERATED_CONFIG_FILES.has(base);
32
+ }
33
+
15
34
  /**
16
35
  * @param {string} projectDir
17
36
  * @param {{db: object}} deps - an open vec store
@@ -21,7 +40,10 @@ export async function ragIndexCoverage(projectDir, { db }) {
21
40
  const project = projectSlug(projectDir);
22
41
  const matchers = buildMatchers(detectAdaptersForProject(projectDir));
23
42
  const { stdout } = await execa("git", ["-C", projectDir, "ls-files", "-z"]);
24
- const sources = stdout.split("\0").filter((p) => p && matchers.isCodeFile(p) && !matchers.shouldSkip(p));
43
+ const all = stdout.split("\0").filter((p) => p && matchers.isCodeFile(p) && !matchers.shouldSkip(p));
44
+ // Lo que kj genera se informa aparte: ni se exige indexado ni se esconde.
45
+ const generated = all.filter(isGeneratedByHarden);
46
+ const sources = all.filter((p) => !isGeneratedByHarden(p));
25
47
 
26
48
  const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE kind = 'code' AND project_slug = ?").all(project);
27
49
  const indexed = new Set(rows.map((r) => (isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
@@ -44,7 +66,7 @@ export async function ragIndexCoverage(projectDir, { db }) {
44
66
  stale = sources.filter((p) => indexed.has(p) && changed.has(p));
45
67
  }
46
68
 
47
- return { project, total: sources.length, indexed: sources.length - missing.length, missing, stale, lastIndexedCommit, absent: indexed.size === 0 };
69
+ return { project, total: sources.length, indexed: sources.length - missing.length, missing, stale, generated, lastIndexedCommit, absent: indexed.size === 0 };
48
70
  }
49
71
 
50
72
  /**
@@ -57,7 +57,29 @@ export async function ensureContractBlockPresent(projectDir) {
57
57
  * La ruta se pregunta a git para que funcione tambien en un worktree, donde
58
58
  * `.git` es un fichero y no un directorio.
59
59
  */
60
- const LOCAL_ARTIFACTS = [".reviews/", ".kj/", ".kj-ready.json"];
60
+ const LOCAL_ARTIFACTS = [
61
+ ".reviews/", ".kj/", ".kj-ready.json",
62
+ // KJC-BUG-0220: lo que kj escribe DENTRO de .karajan/ y NO viaja. Sin esto
63
+ // se quedaba en tierra de nadie (ni versionado ni ignorado) y salia como `??`
64
+ // en cada git status del equipo. Se enumera en vez de ignorar .karajan/
65
+ // entero a proposito: kj.config.yml y las reglas del agente son del equipo, y
66
+ // esconderselas seria decidir por ellos. Los ficheros del supervisor viajan
67
+ // por su cauce sellado (ADR 0009) y tampoco entran aqui.
68
+ ".karajan/harness/", // el arnes del anfitrion: cada maquina lo regenera
69
+ ".karajan/policy-decisions.jsonl",
70
+ ".karajan/policy-exceptions.jsonl",
71
+ ".karajan/identity.local.yml", // identidad por clon (ADR 0005)
72
+ ".karajan/sentinel-state.json",
73
+ ".karajan/rag.db",
74
+ ".karajan/audit-history.db",
75
+ ".karajan/update-check.json",
76
+ ".karajan/karajan.env",
77
+ ".karajan/agent-skills/",
78
+ ".karajan/audits/",
79
+ ".karajan/reviews/",
80
+ ".karajan/steward/",
81
+ ".karajan/plans/",
82
+ ];
61
83
 
62
84
  export async function excludeLocalArtifacts(projectDir, run = runCommand) {
63
85
  let file = path.join(projectDir, ".git", "info", "exclude");
@@ -0,0 +1,42 @@
1
+ /**
2
+ * kj-provenance — KJC-BUG-0222. Un gate que dice QUE falla sin decir CONTRA QUE
3
+ * compara ensena a saltarselo. El aviso del harness decia "does not match the
4
+ * installed kj" y ahi acababa, asi que en campo se leyo como "kj se
5
+ * desincroniza solo": una sesion se monto el ritual de ejecutar `kj harden`
6
+ * antes de cada review y de cada commit, veinte veces en un dia, sin
7
+ * preguntarse por que. La causa estaba entera en el dato que faltaba: ese kj
8
+ * estaba npm-linkado a un arbol de desarrollo cuyas plantillas cambiaron seis
9
+ * veces en dos dias.
10
+ *
11
+ * Saltarse un gate ritualmente es peor que no tenerlo, porque parece que se
12
+ * cumple.
13
+ */
14
+ import { createRequire } from "node:module";
15
+ import { dirname, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+
20
+ /**
21
+ * Que kj esta juzgando: version, de donde sale y si es un paquete instalado o
22
+ * un arbol de desarrollo enlazado. Nunca lanza: esto SOLO adorna un mensaje, y
23
+ * un fallo aqui jamas puede cambiar el veredicto de un gate.
24
+ *
25
+ * @returns {{version: string|null, root: string, linked: boolean, label: string}}
26
+ */
27
+ export function kjProvenance(moduleDir = here) {
28
+ const root = resolve(moduleDir, "..", "..");
29
+ let version = null;
30
+ try {
31
+ version = createRequire(import.meta.url)(resolve(root, "package.json")).version || null;
32
+ } catch { /* sin manifest legible: se dice lo que se sabe */ }
33
+ // Un kj instalado vive bajo node_modules. Cualquier otra ruta es un arbol de
34
+ // trabajo enlazado (npm link), y entonces sus plantillas se mueven con cada
35
+ // edicion de quien lo desarrolla, en TODOS los proyectos de la maquina.
36
+ const linked = !root.includes(`${"node_modules"}/karajan-code`) && !root.endsWith("node_modules");
37
+ const v = version ? `kj ${version}` : "an unknown kj";
38
+ const label = linked
39
+ ? `${v} LINKED from ${root} (a development tree: its templates move with every edit there, in every project on this machine)`
40
+ : `${v} (${root})`;
41
+ return { version, root, linked, label };
42
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import path from "node:path";
12
- import { verifySentinelScripts } from "../harden/sentinel-hooks.js";
12
+ import { resolveSentinelRoot, verifySentinelScripts } from "../harden/sentinel-hooks.js";
13
13
 
14
14
  /**
15
15
  * @param {string} projectDir
@@ -22,10 +22,18 @@ import { verifySentinelScripts } from "../harden/sentinel-hooks.js";
22
22
  * the requirement fails closed until the human re-runs `kj harden`.
23
23
  */
24
24
  export function readRagLedger(projectDir) {
25
- const harnessDir = path.join(projectDir, ".karajan", "harness");
25
+ // KJC-BUG-0223: el harness vive en la RAIZ del proyecto. Este modulo miraba
26
+ // el directorio recibido (en la practica, process.cwd()) mientras el
27
+ // verificador resolvia el toplevel de git, asi que desde un subdirectorio los
28
+ // dos juzgaban sobre arboles distintos y el gate bloqueaba siempre.
29
+ const root = resolveSentinelRoot(projectDir);
30
+ const harnessDir = path.join(root, ".karajan", "harness");
26
31
  const harness = existsSync(harnessDir);
27
- const check = harness ? verifySentinelScripts({ projectDir }) : { ok: false, mismatched: [] };
28
- const base = { harness, verified: harness && check.ok === true, mismatched: check.mismatched || [], queries: [], hits: [] };
32
+ const check = harness ? verifySentinelScripts({ projectDir: root }) : { ok: false, mismatched: [] };
33
+ // KJC-BUG-0222: `drift` viaja hasta el mensaje. Un guard sellado que se ha
34
+ // quedado atras NO lo arregla `kj harden` (lo deja en paz a proposito), asi
35
+ // que mandar ese comando para ese fichero es un bucle.
36
+ const base = { root, harness, verified: harness && check.ok === true, mismatched: check.mismatched || [], drift: check.drift || [], queries: [], hits: [] };
29
37
  let state;
30
38
  try {
31
39
  state = JSON.parse(readFileSync(path.join(harnessDir, "sentinel-state.json"), "utf8"));
@@ -12,6 +12,7 @@
12
12
  * concepts that are not in the diff. That list travels to the reviewer,
13
13
  * because the bugs of 2026-09-16 were exactly the twin nobody touched.
14
14
  */
15
+ import { kjProvenance } from "./kj-provenance.js";
15
16
  import { sourceFilesOf } from "./tests-with-code.js";
16
17
 
17
18
  export const RAG_RULE_ID = "method.rag.code";
@@ -30,6 +31,17 @@ function liveGrant(standingExceptions, now) {
30
31
  * @param {{available: boolean, reason?: string, queries: object[], hits: string[]}} [args.ledger]
31
32
  * @returns {{ok: boolean, mode: "docs-only"|"pass"|"granted"|"block", reason?: string, grant?: object, sources?: string[], queries?: number, covered?: string[], uncovered?: string[], twinsUntouched?: string[]}}
32
33
  */
34
+ /**
35
+ * KJC-BUG-0222 — el comando que resuelve ESTE estado. `kj harden` repone lo que
36
+ * nadie sello; un guard sellado que se ha quedado atras lo deja en paz a
37
+ * proposito (KJC-BUG-0193), y avanzarlo es acto humano.
38
+ */
39
+ function harnessRemedy(drift = []) {
40
+ return drift.length > 0
41
+ ? `the human runs \`kj harden\` to restore them, and \`kj harden --commit\` for the sealed ones (${drift.join(", ")}), which \`kj harden\` leaves untouched on purpose`
42
+ : "the human runs `kj harden` to regenerate it";
43
+ }
44
+
33
45
  export function checkRagRequirement({ config = {}, stagedFiles = [], newFiles = [], ledger, standingExceptions = [], now = new Date(), env = {} }) {
34
46
  const { sources } = sourceFilesOf(config, stagedFiles);
35
47
  if (sources.length === 0) return { ok: true, mode: "docs-only" };
@@ -55,7 +67,9 @@ export function checkRagRequirement({ config = {}, stagedFiles = [], newFiles =
55
67
  // than the ledger) cannot vouch for anything: fail closed, like the tamper
56
68
  // check does, until the human regenerates it.
57
69
  if (ledger?.harness === true && ledger?.verified === false) {
58
- return { ok: false, mode: "block", sources, reason: `${head} — the Sentinel harness does not match the installed kj (${(ledger.mismatched || []).join(", ") || "scripts missing"}): the human runs \`kj harden\` to regenerate it before code is reviewed. ${GRANT_HINT}` };
70
+ // KJC-BUG-0222: decir CONTRA QUE no coincide. Sin ese dato el aviso se lee
71
+ // como "kj se desincroniza solo" y se automatiza el sintoma.
72
+ return { ok: false, mode: "block", sources, reason: `${head} — the installed Sentinel harness does not match the templates of ${kjProvenance().label} (${(ledger.mismatched || []).join(", ") || "scripts missing"}): ${harnessRemedy(ledger.drift)} before code is reviewed. ${GRANT_HINT}` };
59
73
  }
60
74
  if (!ledger?.available) {
61
75
  return { ok: false, mode: "block", sources, reason: `${head} — ${ledger?.reason || "no session ledger"}: consult the RAG about the change (kj_rag_query / kj rag query) and run \`kj review --staged\` again. ${GRANT_HINT}` };
@@ -82,7 +96,7 @@ export function checkRagRequirement({ config = {}, stagedFiles = [], newFiles =
82
96
  * block — the diff hash pins the file set, so a source missing from
83
97
  * `covered` was never answered about, whatever the block claims.
84
98
  */
85
- export function checkRagVerdict({ config = {}, stagedFiles = [], rag, harness = true, verified = true, mismatched = [], standingExceptions = [], now = new Date() }) {
99
+ export function checkRagVerdict({ config = {}, stagedFiles = [], rag, harness = true, verified = true, mismatched = [], drift = [], standingExceptions = [], now = new Date() }) {
86
100
  const { sources } = sourceFilesOf(config, stagedFiles);
87
101
  if (sources.length === 0) return { ok: true, mode: "docs-only" };
88
102
  const grant = liveGrant(standingExceptions, now);
@@ -94,7 +108,7 @@ export function checkRagVerdict({ config = {}, stagedFiles = [], rag, harness =
94
108
  return { ok: false, mode: "block", sources, reason: `${head} — no Sentinel harness in this tree, so no session ledger can exist: install it with \`kj harden\`. ${GRANT_HINT}` };
95
109
  }
96
110
  if (!verified) {
97
- return { ok: false, mode: "block", sources, reason: `${head} — the Sentinel harness does not match the installed kj (${mismatched.join(", ") || "scripts missing"}): the human runs \`kj harden\` to regenerate it. ${GRANT_HINT}` };
111
+ return { ok: false, mode: "block", sources, reason: `${head} — the installed Sentinel harness does not match the templates of ${kjProvenance().label} (${mismatched.join(", ") || "scripts missing"}): ${harnessRemedy(drift)}. ${GRANT_HINT}` };
98
112
  }
99
113
  if (!rag) {
100
114
  return { ok: false, mode: "block", sources, reason: `${head} — the verdict carries no rag block; run \`kj review --staged\` again. ${GRANT_HINT}` };
package/src/sonar/api.js CHANGED
@@ -82,8 +82,17 @@ async function sonarFetch(config, urlPath) {
82
82
  );
83
83
  }
84
84
 
85
+ /**
86
+ * KJC-BUG-0226 (issue #1838) — de donde sale la clave que se CONSULTA. El
87
+ * scanner sube el analisis con la que el repo declara en su
88
+ * sonar-project.properties; preguntando por otra (la derivada del remote), el
89
+ * audit acababa listando issues de repos ajenos del mismo SonarQube. Pasando
90
+ * el directorio, la resolucion lee esa misma clave declarada.
91
+ */
92
+ const queryCwd = (config) => config?.projectDir || process.cwd();
93
+
85
94
  export async function getQualityGateStatus(config, projectKey = null) {
86
- const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
95
+ const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
87
96
  const body = await sonarFetch(config, `/api/qualitygates/project_status?projectKey=${effectiveProjectKey}`);
88
97
 
89
98
  try {
@@ -95,7 +104,7 @@ export async function getQualityGateStatus(config, projectKey = null) {
95
104
  }
96
105
 
97
106
  export async function getOpenIssues(config, projectKey = null) {
98
- const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
107
+ const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
99
108
  const body = await sonarFetch(config, `/api/issues/search?projectKeys=${effectiveProjectKey}&statuses=OPEN`);
100
109
 
101
110
  let parsedIssues = [];
@@ -1,4 +1,7 @@
1
1
  import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
2
5
  import { runCommand } from "../utils/process.js";
3
6
 
4
7
  function slug(value) {
@@ -55,6 +58,27 @@ function canonicalRepoId(remoteUrl) {
55
58
  return `${host}/${segments.join("/")}`;
56
59
  }
57
60
 
61
+ /**
62
+ * KJC-BUG-0226 (issue #1838) — la clave que el REPO declara en su
63
+ * sonar-project.properties es la que el servidor conoce, porque es con la que
64
+ * el scanner sube el analisis. Sin leerla aqui, kj escaneaba un proyecto y
65
+ * preguntaba por otro (una clave derivada del remote), y el audit acababa
66
+ * listando issues de repos ajenos del mismo SonarQube.
67
+ *
68
+ * Sincrono y tolerante: sin fichero, ilegible o sin la clave, se sigue por
69
+ * donde se iba.
70
+ */
71
+ function declaredProjectKey(cwd) {
72
+ try {
73
+ const raw = fs.readFileSync(path.join(cwd, "sonar-project.properties"), "utf8");
74
+ for (const line of raw.split("\n")) {
75
+ const m = /^\s*sonar\.projectKey\s*=\s*(.+?)\s*$/.exec(line);
76
+ if (m) return m[1];
77
+ }
78
+ } catch { /* sin properties legible: se deriva como siempre */ }
79
+ return null;
80
+ }
81
+
58
82
  export async function resolveSonarProjectKey(config, options = {}) {
59
83
  const explicit = String(
60
84
  options.projectKey || process.env.KJ_SONAR_PROJECT_KEY || config?.sonarqube?.project_key || ""
@@ -63,6 +87,15 @@ export async function resolveSonarProjectKey(config, options = {}) {
63
87
  return normalizeProjectKey(explicit);
64
88
  }
65
89
 
90
+ // KJC-BUG-0226: lo que el repo declara manda sobre lo que kj derivaria, que
91
+ // es justo lo que el scanner ya hace al subir el analisis. Solo con un `cwd`
92
+ // explicito: quien no dice que directorio le importa se queda como estaba, y
93
+ // asi esto no cambia el comportamiento de nadie mas que el de la consulta.
94
+ if (options.cwd) {
95
+ const declared = declaredProjectKey(options.cwd);
96
+ if (declared) return normalizeProjectKey(declared);
97
+ }
98
+
66
99
  const remote = await runCommand("git", ["config", "--get", "remote.origin.url"]);
67
100
  const remoteUrl = String(remote.stdout || "").trim();
68
101
  if (remote.exitCode !== 0 || !remoteUrl) {
@@ -50,8 +50,38 @@ export function indexedFilesFrom(scanOutput) {
50
50
  return files;
51
51
  }
52
52
 
53
- export function buildScannerOpts(projectKey, scanner = {}) {
53
+ /**
54
+ * KJC-BUG-0225 — `.git` es un fichero (no un directorio) en un worktree
55
+ * enlazado y en un submodulo: exactamente los dos casos que JGit no sabe abrir.
56
+ * Sin proceso y sin depender de que git responda.
57
+ */
58
+ export function isLinkedWorktree(dir) {
59
+ try {
60
+ return fs.statSync(path.join(dir, ".git")).isFile();
61
+ } catch {
62
+ return false; // sin .git no hay SCM que desactivar
63
+ }
64
+ }
65
+
66
+ /**
67
+ * KJC-BUG-0225 (issue #1839) — dentro de un carril de `kj worktree start`, el
68
+ * plugin SCM del scanner (JGit) no sabe abrir un worktree enlazado (`.git` es
69
+ * un fichero que apunta a `.git/worktrees/<carril>`) y revienta el analisis
70
+ * entero. El pre-gate lo reportaba como "UNAVAILABLE" y la ejecucion seguia:
71
+ * todo cambio hecho como el metodo manda, un carril por tarea, se quedaba sin
72
+ * quality gate y sin un rojo que lo dijera.
73
+ *
74
+ * Se desactiva SOLO el SCM, que en un carril no aporta nada (alimenta blame y
75
+ * asignacion de autoria); las reglas, la cobertura y el resto del analisis se
76
+ * quedan exactamente igual.
77
+ *
78
+ * @param {string} projectKey
79
+ * @param {object} scanner config del scanner (scm_disabled lo fuerza siempre)
80
+ * @param {{linkedWorktree?: boolean}} [where]
81
+ */
82
+ export function buildScannerOpts(projectKey, scanner = {}, where = {}) {
54
83
  const opts = [`-Dsonar.projectKey=${projectKey}`];
84
+ if (scanner.scm_disabled || where.linkedWorktree) opts.push("-Dsonar.scm.disabled=true");
55
85
  // KJC-TSK-0838: verbose is what makes the scanner name every indexed file.
56
86
  if (scanner.verbose) opts.push("-Dsonar.verbose=true");
57
87
  if (scanner.sources) opts.push(`-Dsonar.sources=${scanner.sources}`);
@@ -317,11 +347,16 @@ export async function runSonarScan(config, projectKey = null, { verbose = false,
317
347
  : null;
318
348
 
319
349
  const pick = await pickSonarScanner(sonarConfig.scanner);
350
+ // KJC-BUG-0225: en un carril, el plugin SCM del scanner (JGit) no sabe abrir
351
+ // el worktree enlazado y tumba el analisis entero. La senal es la causa
352
+ // literal del error y no necesita proceso: en un worktree enlazado (y en un
353
+ // submodulo, que JGit trata igual) `.git` es un FICHERO, no un directorio.
354
+ const linkedWorktree = isLinkedWorktree(scanCwd);
320
355
  const env = {
321
356
  ...process.env,
322
357
  SONAR_HOST_URL: pick.type === "native" ? rawHost : host,
323
358
  SONAR_TOKEN: token || "",
324
- SONAR_SCANNER_OPTS: buildScannerOpts(effectiveProjectKey, scannerConfig)
359
+ SONAR_SCANNER_OPTS: buildScannerOpts(effectiveProjectKey, scannerConfig, { linkedWorktree })
325
360
  };
326
361
 
327
362
  let cmd, args;