karajan-code 4.34.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.
@@ -5,7 +5,7 @@
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { computeBaseRef, setSnapshot } from "../review/diff-generator.js";
8
- import { ensureContractBlockPresent } from "../review/gate-gitignore.js";
8
+ import { ensureContractBlockPresent, excludeLocalArtifacts } from "../review/gate-gitignore.js";
9
9
  import { revParse } from "../utils/git.js";
10
10
  import { buildCoderPrompt } from "../prompts/coder.js";
11
11
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
@@ -55,26 +55,18 @@ export async function autoInit(projectDir, logger) {
55
55
  }
56
56
  }
57
57
 
58
- // Ensure .gitignore exists with universal entries only (stack-specific added after planner)
59
- // KJC-BUG-0123: `.karajan/` is NOT in this list on purpose — a bare
60
- // dir-exclude breaks the gate contract (git cannot re-include children);
61
- // the canonical block is appended below from its single source.
62
- const gitignorePath = path.join(projectDir, ".gitignore");
63
- const universalIgnores = [".env", "*.log", ".DS_Store", ".reviews/"];
58
+ // KJC-BUG-0213 (issue #1734): el .gitignore es del equipo y viaja con el repo.
59
+ // Ahi solo entra el contrato v4, que el equipo entero hereda; los artefactos
60
+ // de una ejecucion son nuestros y locales, asi que van a .git/info/exclude.
61
+ // Lo que kj metia antes (.env, *.log, .DS_Store) era opinion sobre un
62
+ // proyecto ajeno, y aparecia en el diff de otra persona sin haberlo pedido.
64
63
  try {
65
- let content = "";
66
- if (await exists(gitignorePath)) {
67
- content = await fs.readFile(gitignorePath, "utf8");
68
- }
69
- const missing = universalIgnores.filter(entry => !content.includes(entry));
70
- if (missing.length > 0) {
71
- const append = (content && !content.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n";
72
- await fs.appendFile(gitignorePath, append, "utf8");
73
- logger.info(`Created .gitignore with universal entries`);
74
- }
75
- await ensureContractBlockPresent(projectDir);
64
+ const contract = await ensureContractBlockPresent(projectDir);
65
+ if (contract.changed) logger.info("El contrato v4 se ha anadido a .gitignore (lo hereda quien clone)");
66
+ const local = await excludeLocalArtifacts(projectDir);
67
+ if (local.changed) logger.info(`Artefactos de kj excluidos en .git/info/exclude: ${local.added.join(", ")}`);
76
68
  } catch (err) {
77
- logger.warn(`Failed to create .gitignore: ${err.message}`);
69
+ logger.warn(`Failed to prepare .gitignore: ${err.message}`);
78
70
  }
79
71
 
80
72
  const karajanDir = path.join(projectDir, ".karajan");
@@ -125,89 +117,11 @@ export async function autoInit(projectDir, logger) {
125
117
  }
126
118
  }
127
119
 
128
- // Stack-specific .gitignore patterns keyed by language/framework
129
- const STACK_GITIGNORE = {
130
- javascript: ["node_modules/", "dist/", "build/", "coverage/", ".cache/", "*.tsbuildinfo"],
131
- typescript: ["node_modules/", "dist/", "build/", "coverage/", ".cache/", "*.tsbuildinfo"],
132
- python: ["__pycache__/", "*.pyc", ".venv/", "venv/", "*.egg-info/", ".pytest_cache/", "htmlcov/", ".mypy_cache/"],
133
- java: ["target/", "*.class", "*.jar", "*.war", ".gradle/", "build/", ".settings/", ".classpath", ".project"],
134
- kotlin: ["target/", "*.class", "build/", ".gradle/", ".kotlin/"],
135
- go: ["bin/", "*.exe", "vendor/"],
136
- rust: ["target/", "Cargo.lock"],
137
- ruby: ["vendor/bundle/", ".bundle/", "coverage/", "tmp/"],
138
- php: ["vendor/", ".phpunit.result.cache", "storage/logs/"],
139
- csharp: ["bin/", "obj/", "*.suo", "*.user", "packages/", ".vs/"],
140
- swift: [".build/", "Packages/", "*.xcodeproj/", "DerivedData/"],
141
- dart: [".dart_tool/", "build/", ".packages"],
142
- };
143
-
144
- /**
145
- * Update .gitignore with stack-specific entries after planner/architect decides the stack.
146
- * Detects stack from: triage taskType, architect output, planner output, or task keywords.
147
- */
148
- export async function updateGitignoreForStack(projectDir, { stageResults, task, logger }) {
149
- const gitignorePath = path.join(projectDir, ".gitignore");
150
- const detected = new Set();
151
-
152
- // From architect — if it chose layers/patterns, it may hint at the language
153
- const arch = stageResults?.architect?.architecture;
154
- if (arch) {
155
- const archText = JSON.stringify(arch).toLowerCase();
156
- for (const lang of Object.keys(STACK_GITIGNORE)) {
157
- if (archText.includes(lang)) detected.add(lang);
158
- }
159
- }
160
-
161
- // From planner — scan plan text for language keywords
162
- const planText = (stageResults?.planner?.plan || "").toLowerCase();
163
- // From task description
164
- const taskText = (task || "").toLowerCase();
165
- const combined = `${planText} ${taskText}`;
166
-
167
- const langKeywords = {
168
- javascript: ["node", "npm", "express", "react", "vue", "next", "vite", "vitest", "jest", "pnpm", "yarn", "javascript", "js"],
169
- typescript: ["typescript", "tsx", "tsc"],
170
- python: ["python", "django", "flask", "fastapi", "pip", "pytest", "poetry"],
171
- java: ["java", "spring", "maven", "gradle", "junit"],
172
- kotlin: ["kotlin", "ktor"],
173
- go: ["golang", "go mod", "gin", "fiber"],
174
- rust: ["rust", "cargo", "tokio"],
175
- ruby: ["ruby", "rails", "gem", "bundler", "rspec"],
176
- php: ["php", "laravel", "composer", "symfony"],
177
- csharp: ["c#", "csharp", "dotnet", ".net", "aspnet"],
178
- swift: ["swift", "swiftui", "vapor"],
179
- dart: ["dart", "flutter"],
180
- };
181
-
182
- for (const [lang, keywords] of Object.entries(langKeywords)) {
183
- if (keywords.some(kw => combined.includes(kw))) detected.add(lang);
184
- }
185
-
186
- if (detected.size === 0) return;
187
-
188
- // Collect all entries for detected stacks
189
- const entries = [];
190
- for (const lang of detected) {
191
- entries.push(...(STACK_GITIGNORE[lang] || []));
192
- }
193
- const unique = [...new Set(entries)];
194
-
195
- try {
196
- let content = "";
197
- if (await exists(gitignorePath)) {
198
- content = await fs.readFile(gitignorePath, "utf8");
199
- }
200
- const missing = unique.filter(entry => !content.includes(entry));
201
- if (missing.length > 0) {
202
- const header = `\n# ${[...detected].join(" + ")} project\n`;
203
- const append = header + missing.join("\n") + "\n";
204
- await fs.appendFile(gitignorePath, append, "utf8");
205
- logger.info(`Updated .gitignore for ${[...detected].join(" + ")}: ${missing.join(", ")}`);
206
- }
207
- } catch (err) {
208
- logger.warn(`Failed to update .gitignore for stack: ${err.message}`);
209
- }
210
- }
120
+ // KJC-BUG-0213 (issue #1734): aqui vivia updateGitignoreForStack, que deducia
121
+ // el stack buscando palabras como "ts" o "js" en el texto del plan y de la
122
+ // tarea. Cualquier frase las contiene, asi que un proyecto Laravel acabo con
123
+ // reglas de TypeScript en SU .gitignore. Un acierto por casualidad no vale para
124
+ // escribir en un fichero que no es nuestro: la funcion se retira entera.
211
125
 
212
126
  /**
213
127
  * Load product context from well-known file locations.
@@ -24,10 +24,17 @@ export async function runGuardStages({ config, logger, emitter, eventBase, sessi
24
24
 
25
25
  if (!outputEnabled && !perfEnabled) return { action: "ok" };
26
26
 
27
+ // KJC-BUG-0214 (issue #1733): los guards juzgan LO QUE HA HECHO ESTE RUN, no
28
+ // la rama entera. Con el merge-base contra la base, cualquier linea anadida
29
+ // en cualquier momento de la rama contaba como recien escrita, y una
30
+ // ejecucion se bloqueo por codigo preexistente que el coder no toco. La
31
+ // sesion ya guarda HEAD al arrancar precisamente para esto.
27
32
  const baseBranch = config.base_branch || "main";
33
+ const runStart = session?.head_at_start || session?.session_start_sha || null;
28
34
  let diff;
29
35
  try {
30
- const baseRef = await computeBaseRef({ baseBranch });
36
+ const baseRef = runStart || await computeBaseRef({ baseBranch });
37
+ if (!runStart) logger.warn("Guards: sin punto de arranque del run, se juzga la rama entera contra la base");
31
38
  diff = await generateDiff({ baseRef, projectDir: config?.projectDir || null });
32
39
  } catch {
33
40
  logger.warn("Guards: could not generate diff, skipping");
@@ -57,7 +57,7 @@ import { maybeGenerateAutoHuBatch } from "./pre-loop-phases/auto-hu-batch.js";
57
57
 
58
58
  import {
59
59
  applyTriageOverrides, applyAutoSimplify, applyFlagOverrides,
60
- resolvePipelinePolicies, updateGitignoreForStack,
60
+ resolvePipelinePolicies,
61
61
  } from "../config-init.js";
62
62
  import { tryCiComment } from "../ci-integration.js";
63
63
  import { getIntegration } from "../integrations.js";
@@ -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}`
@@ -350,7 +350,6 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
350
350
 
351
351
  // --- Update .gitignore with stack-specific entries based on planner/architect output ---
352
352
  const projectDir = updatedConfig.projectDir || process.cwd();
353
- await updateGitignoreForStack(projectDir, { stageResults, task, logger });
354
353
 
355
354
  // --- Auto-HU: when triage recommended decomposition and no manual huFile, generate HU batch ---
356
355
  await maybeGenerateAutoHuBatch({
@@ -8,10 +8,29 @@
8
8
  * matchers are the single truth, here and in the indexer.
9
9
  */
10
10
  import { execa } from "execa";
11
- import { isAbsolute, relative } from "node:path";
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,5 +66,36 @@ 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 };
70
+ }
71
+
72
+ /**
73
+ * KJC-BUG-0216 (issue #1807) — POR QUE el RAG no puede responder sobre un
74
+ * fichero. El gate rag-first exigia una respuesta sobre cualquier fichero
75
+ * tocado, y para un .astro (o .php, o cualquier lenguaje sin adapter) eso es
76
+ * imposible: el indexador nunca lo toma. La unica salida era KJ_ALLOW_NO_RAG
77
+ * puesta por costumbre en todos los comandos, y con ella puesta el gate deja de
78
+ * informar y borra la diferencia entre "no pude consultar" y "no quise".
79
+ *
80
+ * `canAnswer` es la respuesta que el gate necesita: solo cuando es true tiene
81
+ * sentido exigir que el RAG haya hablado de ese fichero.
82
+ *
83
+ * @returns {{state: "indexed"|"stale"|"not-indexable"|"index-empty", rel: string, canAnswer: boolean, reason: string}}
84
+ */
85
+ export function fileIndexState(projectDir, file, { db }) {
86
+ const project = projectSlug(projectDir);
87
+ // `./src/a.js` y `src/a.js` son el mismo fichero: sin normalizar, el segundo
88
+ // salia "stale" estando indexado (catch de la review).
89
+ const rel = normalize(isAbsolute(file) ? relative(projectDir, file) : file);
90
+ const matchers = buildMatchers(detectAdaptersForProject(projectDir));
91
+ const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE project_slug = ?").all(project);
92
+ const indexed = new Set(rows.map((r) => normalize(isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
93
+
94
+ if (indexed.has(rel)) return { state: "indexed", rel, canAnswer: true, reason: `the index holds chunks for ${rel}` };
95
+ if (indexed.size === 0) return { state: "index-empty", rel, canAnswer: false, reason: `nothing of this project is indexed yet: kj rag index` };
96
+ if (matchers.shouldSkip(rel)) return { state: "not-indexable", rel, canAnswer: false, reason: `${rel} lives under a path the indexer always skips` };
97
+ if (!matchers.isCodeFile(rel) && extname(rel).toLowerCase() !== ".md") {
98
+ return { state: "not-indexable", rel, canAnswer: false, reason: `no language adapter covers ${extname(rel) || "a file with no extension"}, so the indexer never takes ${rel}` };
99
+ }
100
+ return { state: "stale", rel, canAnswer: false, reason: `${rel} is indexable but absent from the index: kj rag index --since auto` };
48
101
  }
@@ -93,6 +93,18 @@ export async function checkCardFirst({ config = {}, projectDir = process.cwd(),
93
93
  ? { ok: false, mode: "block", reason, level: v.level }
94
94
  : { ok: true, mode: "warn", reason, level: v.level };
95
95
  }
96
+ // KJC-TSK-0878 (issue #1371): "no he podido verificar" y "he verificado y
97
+ // esta mal" no son lo mismo, y ambas pueden merecer una parada. El proyecto
98
+ // decide si la primera para, y el mensaje dice cual de las dos es: un gate
99
+ // que aprueba lo que no ha comprobado da una confianza que no ha ganado.
100
+ // Sin adaptador declarado no hay nada que bloquear: la opcion no castiga a
101
+ // quien aun no tiene verify_cmd.
102
+ if (v.verified === null && config.board?.unverifiable === "block" && config.board?.verify_cmd) {
103
+ return {
104
+ ok: false, mode: "block", level: v.level,
105
+ reason: `branch references ${ref} but the card could not be verified against ${boardName} — ${v.note} (board.unverifiable: block refuses to approve a check kj did not make)`,
106
+ };
107
+ }
96
108
  return { ok: true, mode: "pass", ref, level: v.level, ...(v.note ? { note: v.note } : {}) };
97
109
  }
98
110
  const reason = `no card reference in branch "${branch}" — card-first on ${boardName}: create the card there and name the branch after it (e.g. feat/ABC-123-summary)`;
@@ -12,6 +12,8 @@
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
 
15
+ import { runCommand } from "../utils/process.js";
16
+
15
17
  // KJC-BUG-0123 (issue #1268): exported as the SINGLE source of truth — kj
16
18
  // init and the orchestrator's autoInit write this same block instead of a
17
19
  // bare `.karajan/` exclude (which git cannot re-include children of).
@@ -48,6 +50,59 @@ export async function ensureContractBlockPresent(projectDir) {
48
50
  return { changed: true };
49
51
  }
50
52
 
53
+ /**
54
+ * KJC-BUG-0213 (issue #1734): lo que kj deja en el repo durante una ejecucion
55
+ * es suyo y es local, asi que se excluye donde no molesta a nadie:
56
+ * `.git/info/exclude` no viaja con el repo ni aparece en el diff de otro.
57
+ * La ruta se pregunta a git para que funcione tambien en un worktree, donde
58
+ * `.git` es un fichero y no un directorio.
59
+ */
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
+ ];
83
+
84
+ export async function excludeLocalArtifacts(projectDir, run = runCommand) {
85
+ let file = path.join(projectDir, ".git", "info", "exclude");
86
+ try {
87
+ const res = await run("git", ["-C", projectDir, "rev-parse", "--git-path", "info/exclude"]);
88
+ const out = res?.stdout?.trim();
89
+ if (res?.exitCode === 0 && out) file = path.isAbsolute(out) ? out : path.join(projectDir, out);
90
+ } catch { /* sin git utilizable: se intenta la ruta por defecto */ }
91
+ let text = "";
92
+ try { text = (await fs.readFile(file, "utf8")) || ""; } catch { /* aun no existe */ }
93
+ const lines = new Set(text.split("\n").map((l) => l.trim()));
94
+ const missing = LOCAL_ARTIFACTS.filter((a) => !lines.has(a));
95
+ if (missing.length === 0) return { changed: false, added: [] };
96
+ try {
97
+ await fs.mkdir(path.dirname(file), { recursive: true });
98
+ const sep = text && !text.endsWith("\n") ? "\n" : "";
99
+ await fs.appendFile(file, `${sep}# Karajan run artifacts (local only, never shared)\n${missing.join("\n")}\n`, "utf8");
100
+ return { changed: true, added: missing };
101
+ } catch {
102
+ return { changed: false, added: [] };
103
+ }
104
+ }
105
+
51
106
  export async function ensureGateTrackable(projectDir) {
52
107
  const file = path.join(projectDir, ".gitignore");
53
108
  let text;
@@ -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;