karajan-code 4.35.0 → 4.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/packages/hu-board/public/utils/board-view.js +2 -1
  3. package/packages/hu-board/public/utils/config-editor.js +5 -2
  4. package/packages/hu-board/public/utils/terminal-panel.js +2 -1
  5. package/packages/hu-board/src/config-yaml.js +11 -9
  6. package/packages/hu-board/src/project-dir.js +42 -0
  7. package/packages/hu-board/src/routes/api.js +11 -3
  8. package/packages/hu-board/src/terminal-wire.js +5 -1
  9. package/packages/hu-board/src/terminal.js +8 -3
  10. package/src/audit/harness-scorecard.js +8 -1
  11. package/src/audit/harness-section.js +13 -2
  12. package/src/checks/rag-coverage.js +9 -5
  13. package/src/checks/runner.js +27 -5
  14. package/src/checks/skills.js +10 -0
  15. package/src/checks/types.js +5 -1
  16. package/src/cli/register-meta.js +15 -4
  17. package/src/commands/board.js +20 -8
  18. package/src/commands/bootstrap.js +2 -0
  19. package/src/commands/env.js +8 -4
  20. package/src/commands/harden.js +26 -4
  21. package/src/commands/init.js +2 -1
  22. package/src/commands/plan/generate.js +4 -6
  23. package/src/commands/rag.js +69 -10
  24. package/src/commands/review-gate.js +15 -6
  25. package/src/commands/watch.js +13 -10
  26. package/src/config/defaults.js +3 -2
  27. package/src/environment/playbook.js +10 -3
  28. package/src/harden/config-templates.js +11 -0
  29. package/src/harden/guidelines-engine.js +24 -7
  30. package/src/harden/harness-hooks.js +92 -3
  31. package/src/harden/sentinel-hooks.js +80 -8
  32. package/src/lang/registry.js +2 -1
  33. package/src/mcp/handlers/rag-handler.js +14 -4
  34. package/src/orchestrator/drivers/post-loop.js +13 -11
  35. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
  36. package/src/orchestrator/drivers/pre-loop.js +1 -1
  37. package/src/orchestrator/stages/rag-context-stage.js +5 -2
  38. package/src/rag/auto-update.js +5 -3
  39. package/src/rag/coverage.js +36 -13
  40. package/src/rag/indexable.js +97 -0
  41. package/src/rag/indexer.js +31 -13
  42. package/src/rag/library.js +19 -13
  43. package/src/rag/migrate.js +73 -0
  44. package/src/rag/onnx-fallback.js +5 -3
  45. package/src/rag/project-store.js +48 -0
  46. package/src/rag/watcher.js +25 -14
  47. package/src/review/gate-gitignore.js +23 -1
  48. package/src/review/kj-provenance.js +68 -0
  49. package/src/review/rag-ledger.js +12 -4
  50. package/src/review/rag-requirement.js +17 -3
  51. package/src/review/verdict-store.js +6 -1
  52. package/src/sonar/api.js +11 -2
  53. package/src/sonar/project-key.js +33 -0
  54. package/src/sonar/scanner.js +37 -2
  55. package/src/start/sweep.js +4 -3
  56. package/src/utils/rulesync.js +61 -0
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * library — the distilled engineering canon as its own RAG collection
3
- * (KJC-TSK-0697). Markdown cards from `<pkg>/library/`, `~/.karajan/library/`
4
- * and `<project>/.karajan/library/` index into the SAME global rag.db,
5
- * isolated by project="library" + kind="library", so `kj rag query --library`
6
- * reaches the canon and normal project queries never see it. The architect
3
+ * (KJC-TSK-0697). KJC-TSK-0883 (ADR 0011): the machine canon (`<pkg>/library/`
4
+ * and `~/.karajan/library/`) indexes into its own ~/.karajan/library.db under
5
+ * project="library", reached by `kj rag query --library`; a project's own cards
6
+ * (`<project>/.karajan/library/`) go to that project's index under its slug. The architect
7
7
  * consults it to ground the greenfield alternative (KJC-TSK-0696).
8
8
  */
9
9
 
@@ -20,10 +20,16 @@ export const LIBRARY_PROJECT = "library";
20
20
 
21
21
  const SHIPPED_LIBRARY_DIR = fileURLToPath(new URL("../../library", import.meta.url));
22
22
 
23
- /** Existing library dirs, shipped canon first. */
24
- export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.homedir(), projectDir = process.cwd() } = {}) {
25
- const candidates = [pkgLibraryDir, join(home, ".karajan", "library"), join(projectDir, ".karajan", "library")];
26
- return candidates.filter((d) => existsSync(d));
23
+ /**
24
+ * Existing library dirs, shipped canon first. KJC-TSK-0883: `scope` "machine"
25
+ * is the common canon (kj's + the user's), "project" the project's own cards.
26
+ */
27
+ export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.homedir(), projectDir = process.cwd(), scope = "all" } = {}) {
28
+ const machine = [pkgLibraryDir, join(home, ".karajan", "library")];
29
+ const own = [join(projectDir, ".karajan", "library")];
30
+ const byScope = { machine, project: own, all: [...machine, ...own] };
31
+ if (!byScope[scope]) throw new Error(`libraryDirs: unknown scope "${scope}" (machine | project | all)`);
32
+ return byScope[scope].filter((d) => existsSync(d));
27
33
  }
28
34
 
29
35
  /**
@@ -31,9 +37,9 @@ export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.hom
31
37
  * re-index per source and identical bodies skip the embedder (content-hash
32
38
  * dedup within the library project).
33
39
  */
34
- export async function indexLibrary({ db, embedder, logger = console, pkgLibraryDir, home, projectDir } = {}) {
40
+ export async function indexLibrary({ db, embedder, logger = console, pkgLibraryDir, home, projectDir, scope = "all", project = LIBRARY_PROJECT } = {}) {
35
41
  let indexed = 0, failed = 0, files = 0;
36
- for (const dir of libraryDirs({ pkgLibraryDir, home, projectDir })) {
42
+ for (const dir of libraryDirs({ pkgLibraryDir, home, projectDir, scope })) {
37
43
  for (const name of readdirSync(dir)) {
38
44
  if (extname(name).toLowerCase() !== ".md") continue;
39
45
  const path = join(dir, name);
@@ -42,13 +48,13 @@ export async function indexLibrary({ db, embedder, logger = console, pkgLibraryD
42
48
  const hashed = chunks.map((ch) => ({ ch, contentHash: createHash("sha256").update(ch.text).digest("hex") }));
43
49
  // Unchanged card (every chunk body already known) → skip before the
44
50
  // delete, so idempotent re-runs never touch the embedder.
45
- if (hashed.length && hashed.every(({ contentHash }) => findChunkByHash(db, contentHash, LIBRARY_PROJECT))) continue;
51
+ if (hashed.length && hashed.every(({ contentHash }) => findChunkByHash(db, contentHash, project))) continue;
46
52
  deleteChunksBySource(db, path);
47
53
  for (const { ch, contentHash } of hashed) {
48
54
  try {
49
- if (findChunkByHash(db, contentHash, LIBRARY_PROJECT)) continue;
55
+ if (findChunkByHash(db, contentHash, project)) continue;
50
56
  const embedding = await embedder.embed(ch.text);
51
- insertChunk(db, { source: path, kind: "library", text: ch.text, metadata: ch.metadata, embedding, project: LIBRARY_PROJECT, contentHash });
57
+ insertChunk(db, { source: path, kind: "library", text: ch.text, metadata: ch.metadata, embedding, project, contentHash });
52
58
  indexed += 1;
53
59
  } catch (err) {
54
60
  failed += 1;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * KJC-TSK-0888 (RAG-P1a, ADR 0011) — pasar al indice por proyecto sin volver a
3
+ * embeber. Los chunks de UN proyecto se copian de la base global de la maquina
4
+ * a <raiz>/.karajan/rag.db con sus embeddings tal cual, junto con su marca de
5
+ * last_indexed_commit para que el auto-update siga por delta. Cero recomputo
6
+ * (en esta maquina, karajan-code son 23.109 chunks), y la base global no se
7
+ * toca: borrarla es decision del usuario, no efecto de una migracion.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+
11
+ import { openVecStore, insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
12
+
13
+ export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
14
+
15
+ /**
16
+ * KJC-TSK-0882: que decir cuando el indice del proyecto no tiene nada suyo.
17
+ * Si la base global tiene sus chunks, migrar (segundos); si no, indexar.
18
+ */
19
+ export function emptyIndexRemedy({ slug, legacyPath, dim = 768 }) {
20
+ if (slug && existsSync(legacyPath)) {
21
+ const legacy = openVecStore({ dim, path: legacyPath });
22
+ try {
23
+ if (countProjectChunks(legacy, slug) > 0) return "kj rag migrate";
24
+ } finally {
25
+ legacy.close();
26
+ }
27
+ }
28
+ return "kj rag index --with-sources";
29
+ }
30
+
31
+ /**
32
+ * @returns {{state: "migrated"|"already"|"nothing", migrated: number, reason: string}}
33
+ */
34
+ export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 }) {
35
+ const nothing = { state: "nothing", migrated: 0, reason: `the global index holds nothing for ${slug}: kj rag index --with-sources` };
36
+ if (!existsSync(legacyPath)) return nothing;
37
+
38
+ const target = openVecStore({ dim, path: targetPath });
39
+ try {
40
+ // Se cuentan los de ESTE proyecto, no el total: un rag.db viejo en
41
+ // .karajan/ puede traer chunks de otro (visto en esta maquina: 42 de otro
42
+ // repo), y contar el total diria "ya migrado" sin haber copiado nada.
43
+ const mine = countProjectChunks(target, slug);
44
+ if (mine > 0) {
45
+ return { state: "already", migrated: 0, reason: `the project index already holds ${mine} chunks of ${slug}: nothing to migrate` };
46
+ }
47
+ const legacy = openVecStore({ dim, path: legacyPath });
48
+ try {
49
+ const rows = legacy.prepare("SELECT id, source, kind, text, metadata, content_hash FROM chunks WHERE project_slug = ?").all(slug);
50
+ if (rows.length === 0) return nothing;
51
+ const vecOf = legacy.prepare("SELECT embedding FROM vec_chunks WHERE rowid = ?");
52
+ const copy = target.transaction(() => {
53
+ for (const r of rows) {
54
+ const raw = vecOf.get(BigInt(r.id))?.embedding;
55
+ if (!raw) continue; // un chunk sin vector no se puede buscar: no se inventa
56
+ const embedding = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
57
+ insertChunk(target, { source: r.source, kind: r.kind, text: r.text, metadata: r.metadata, embedding, project: slug, contentHash: r.content_hash });
58
+ }
59
+ });
60
+ copy();
61
+ const stamp = getLastIndexedCommit(legacy, slug);
62
+ if (stamp) setLastIndexedCommit(target, slug, stamp);
63
+ const migrated = countProjectChunks(target, slug);
64
+ const parts = [`${migrated} chunks of ${slug} copied with their embeddings`];
65
+ if (stamp) parts.push(`indexed at ${stamp.slice(0, 9)}`);
66
+ return { state: "migrated", migrated, reason: parts.join(", ") };
67
+ } finally {
68
+ legacy.close();
69
+ }
70
+ } finally {
71
+ target.close();
72
+ }
73
+ }
@@ -12,7 +12,7 @@ import path from "node:path";
12
12
  import Database from "better-sqlite3";
13
13
  import { parseDocument } from "yaml";
14
14
  import { getProjectConfigPath } from "../config/loader.js";
15
- import { dbPath } from "./vec-store.js";
15
+ import { projectDbPath } from "./project-store.js";
16
16
 
17
17
  const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
18
18
 
@@ -24,8 +24,10 @@ const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
24
24
  * that already HAS chunks is never touched — switching dims there would
25
25
  * destroy other projects' indexes.
26
26
  */
27
- export function resetEmptyStore() {
28
- const storePath = dbPath();
27
+ // KJC-TSK-0882 (ADR 0011): el store es DEL PROYECTO, asi que el fallback a ONNX
28
+ // decide mirando el indice de este proyecto y nunca el de otro.
29
+ export function resetEmptyStore(projectDir = process.cwd()) {
30
+ const storePath = projectDbPath(projectDir);
29
31
  if (!existsSync(storePath)) return true;
30
32
  // Read-only, schema-agnostic inspection: no DDL, no vec extension, no
31
33
  // dimension assumptions — the store is only ever OPENED to be counted.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * KJC-TSK-0882 (RAG-P1, ADR 0011) — el indice del RAG es DEL PROYECTO.
3
+ *
4
+ * Hasta aqui era una sola base por maquina (~/.karajan/rag.db): 427 MB, 19
5
+ * proyectos y 49.825 chunks en un indice vectorial sin particion. La KNN sacaba
6
+ * los vecinos mas cercanos de TODA la maquina y filtraba por proyecto despues,
7
+ * asi que un proyecto pequeno podia recibir cero resultados con el chunk
8
+ * perfecto indexado, y cinco consultas ni siquiera filtraban (una de ellas, el
9
+ * preload que entra en el prompt del coder).
10
+ *
11
+ * Ahora cada proyecto abre <raiz git>/.karajan/rag.db. La raiz se resuelve por
12
+ * git para que desde un subdirectorio se vea el mismo indice. KJ_RAG_DB sigue
13
+ * mandando cuando esta puesta. El store de karajan-core ya acepta `path`, asi
14
+ * que esto no toca core.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import path from "node:path";
18
+
19
+ import { openVecStore } from "./vec-store.js";
20
+ import { getKarajanHome } from "../utils/paths.js";
21
+
22
+ function projectRoot(dir) {
23
+ try {
24
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || dir;
25
+ } catch {
26
+ return dir;
27
+ }
28
+ }
29
+
30
+ /** Ruta del indice del proyecto. */
31
+ export function projectDbPath(projectDir = process.cwd()) {
32
+ if (process.env.KJ_RAG_DB) return process.env.KJ_RAG_DB;
33
+ return path.join(projectRoot(projectDir), ".karajan", "rag.db");
34
+ }
35
+
36
+ /** Abre el indice del proyecto. El caller es dueno del close(). */
37
+ /** KJC-TSK-0883: the machine canon has its own store, never a project's. */
38
+ export function libraryDbPath() {
39
+ return process.env.KJ_LIBRARY_DB || path.join(getKarajanHome(), "library.db");
40
+ }
41
+
42
+ export function openLibraryStore({ dim = 768 } = {}) {
43
+ return openVecStore({ dim, path: libraryDbPath() });
44
+ }
45
+
46
+ export function openProjectStore({ projectDir = process.cwd(), dim = 768 } = {}) {
47
+ return openVecStore({ dim, path: projectDbPath(projectDir) });
48
+ }
@@ -1,18 +1,27 @@
1
1
  // KJC-TSK-0441 (RAG v2.28.0 PR1) — chokidar watcher. Live re-index of plans
2
2
  // + onboarding + (opt) sources, debounced. PID file arbitrates a single daemon.
3
3
  // KJC-TSK-0482 — source matcher derivado del registry (multi-lang: JS/Py/Rust/Go/Java).
4
- import { writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";
5
- import { extname, join } from "node:path";
4
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
5
+ import { dirname, extname, join } from "node:path";
6
6
  import chokidar from "chokidar";
7
- import { openVecStore, deleteChunksBySource, projectSlug } from "./vec-store.js";
7
+ import { deleteChunksBySource, projectSlug } from "./vec-store.js";
8
+ import { openProjectStore } from "./project-store.js";
8
9
  import { makeGovernedEmbedder } from "./governed-embedder.js";
9
10
  import { indexFile } from "./indexer.js";
10
11
  import { getKarajanHome } from "../utils/paths.js";
11
12
  import { getAllCodeExtensions } from "../lang/registry.js";
12
13
 
13
- const PIDFILE = () => join(getKarajanHome(), "watcher.pid");
14
+ // KJC-TSK-0883 (ADR 0011): un watcher por proyecto, cada uno con su PID.
15
+ export const pidFilePath = (projectDir = process.cwd()) => join(getKarajanHome(), "watchers", `${projectSlug(projectDir)}.pid`);
16
+
17
+ /** Solo lo de ESTE proyecto: sus planes, su onboarding y (opt) sus fuentes. */
18
+ export function watchedPaths(projectDir, { withSources = false } = {}) {
19
+ const slug = projectSlug(projectDir);
20
+ const paths = [join(getKarajanHome(), "onboarding", `${slug}.md`), join(getKarajanHome(), "plans", slug)];
21
+ return withSources ? [...paths, projectDir] : paths;
22
+ }
14
23
  const DEFAULT_DEBOUNCE_MS = 1000;
15
- const SKIP_SEGMENTS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
24
+ const SKIP_SEGMENTS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet", ".claude"]);
16
25
  const SOURCE_EXTS = new Set(getAllCodeExtensions().map((e) => e.toLowerCase()));
17
26
 
18
27
  export function isWatchable(path) {
@@ -26,11 +35,9 @@ export function startWatcher({ projectDir, config, logger = console, debounceMs
26
35
  if (!projectDir) throw new Error("startWatcher: projectDir is required");
27
36
  const slug = projectSlug(projectDir);
28
37
  const dim = config?.rag?.embedder?.dim || 768;
29
- const db = openVecStore({ dim });
38
+ const db = openProjectStore({ projectDir, dim });
30
39
  const embedder = makeGovernedEmbedder(config);
31
- const paths = [join(getKarajanHome(), "onboarding"), join(getKarajanHome(), "plans")];
32
- if (withSources) paths.push(projectDir);
33
- const watcher = chokidar.watch(paths, { ignoreInitial: true, persistent: true });
40
+ const watcher = chokidar.watch(watchedPaths(projectDir, { withSources }), { ignoreInitial: true, persistent: true });
34
41
  const pending = new Map();
35
42
  const flush = async (p) => {
36
43
  pending.delete(p);
@@ -49,11 +56,15 @@ export function startWatcher({ projectDir, config, logger = console, debounceMs
49
56
  };
50
57
  }
51
58
 
52
- export function writePidFile(pid = process.pid) { writeFileSync(PIDFILE(), String(pid), "utf8"); }
53
- export function clearPidFile() { if (existsSync(PIDFILE())) unlinkSync(PIDFILE()); }
54
- export function readPidFile() {
55
- if (!existsSync(PIDFILE())) return null;
56
- const pid = Number(readFileSync(PIDFILE(), "utf8").trim());
59
+ export function writePidFile(projectDir, pid = process.pid) {
60
+ const file = pidFilePath(projectDir);
61
+ mkdirSync(dirname(file), { recursive: true });
62
+ writeFileSync(file, String(pid), "utf8");
63
+ }
64
+ export function clearPidFile(projectDir) { if (existsSync(pidFilePath(projectDir))) unlinkSync(pidFilePath(projectDir)); }
65
+ export function readPidFile(projectDir) {
66
+ if (!existsSync(pidFilePath(projectDir))) return null;
67
+ const pid = Number(readFileSync(pidFilePath(projectDir), "utf8").trim());
57
68
  return Number.isInteger(pid) && pid > 0 ? pid : null;
58
69
  }
59
70
  export function isPidAlive(pid) {
@@ -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,68 @@
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 { execFileSync } from "node:child_process";
15
+ import { createRequire } from "node:module";
16
+ import { dirname, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * Que kj esta juzgando: version, de donde sale y si es un paquete instalado o
23
+ * un arbol de desarrollo enlazado. Nunca lanza: esto SOLO adorna un mensaje, y
24
+ * un fallo aqui jamas puede cambiar el veredicto de un gate.
25
+ *
26
+ * @returns {{version: string|null, root: string, linked: boolean, label: string}}
27
+ */
28
+ export function kjProvenance(moduleDir = here) {
29
+ const root = resolve(moduleDir, "..", "..");
30
+ let version = null;
31
+ try {
32
+ version = createRequire(import.meta.url)(resolve(root, "package.json")).version || null;
33
+ } catch { /* sin manifest legible: se dice lo que se sabe */ }
34
+ // Un kj instalado vive bajo node_modules. Cualquier otra ruta es un arbol de
35
+ // trabajo enlazado (npm link), y entonces sus plantillas se mueven con cada
36
+ // edicion de quien lo desarrolla, en TODOS los proyectos de la maquina.
37
+ const linked = !root.includes(`${"node_modules"}/karajan-code`) && !root.endsWith("node_modules");
38
+ const v = version ? `kj ${version}` : "an unknown kj";
39
+ const label = linked
40
+ ? `${v} LINKED from ${root} (a development tree: its templates move with every edit there, in every project on this machine)`
41
+ : `${v} (${root})`;
42
+ return { version, root, linked, label };
43
+ }
44
+
45
+ const gitIn = (root) => (args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
46
+
47
+ /**
48
+ * KJC-TSK-0886: el sello de que kj emite un veredicto. En un arbol enlazado la
49
+ * version no basta (el mismo package.json vale para dos estados del disco), asi
50
+ * que lleva rama y commit del working tree. Nunca lanza: adorna, no decide.
51
+ */
52
+ export function kjStamp({ provenance = kjProvenance(), git = gitIn(provenance.root) } = {}) {
53
+ const stamp = { version: provenance.version, linked: provenance.linked };
54
+ if (!provenance.linked) return stamp;
55
+ try {
56
+ return { ...stamp, branch: git(["rev-parse", "--abbrev-ref", "HEAD"]).trim(), commit: git(["rev-parse", "HEAD"]).trim() };
57
+ } catch {
58
+ return stamp;
59
+ }
60
+ }
61
+
62
+ const STAMP_KEYS = ["version", "linked", "branch", "commit"];
63
+ export const stampDiffers = (a, b) => Boolean(a && b) && STAMP_KEYS.some((k) => a[k] !== b[k]);
64
+
65
+ export function stampLabel(s) {
66
+ const where = s.branch ? ` (${s.branch}@${(s.commit || "").slice(0, 7)})` : "";
67
+ return `kj ${s.version ?? "unknown"}${where}`;
68
+ }
@@ -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}` };
@@ -13,6 +13,7 @@ import crypto from "node:crypto";
13
13
  import fs from "node:fs/promises";
14
14
  import path from "node:path";
15
15
  import { runCommand } from "../utils/process.js";
16
+ import { kjStamp, stampDiffers, stampLabel } from "./kj-provenance.js";
16
17
 
17
18
  const STORE_DIR = path.join(".karajan", "reviews");
18
19
 
@@ -35,6 +36,7 @@ export async function saveVerdict(projectDir, diff, verdict) {
35
36
  const hash = diffHash(diff);
36
37
  const record = {
37
38
  ...verdict,
39
+ kj: kjStamp(), // KJC-TSK-0886: que kj lo emitio; nunca lo dicta quien llama
38
40
  diffHash: hash,
39
41
  timestamp: new Date().toISOString(),
40
42
  };
@@ -143,5 +145,8 @@ export async function checkVerdict(projectDir, diff) {
143
145
  if (verdict.verdict !== "approved") {
144
146
  return { ok: false, verdict, reason: `review was rejected by ${verdict.reviewer} — fix the issues and run \`kj review\` again` };
145
147
  }
146
- return { ok: true, verdict };
148
+ // KJC-TSK-0886: otro kj comprobando no bloquea, pero deja rastro.
149
+ const now = kjStamp();
150
+ if (!stampDiffers(verdict.kj, now)) return { ok: true, verdict };
151
+ return { ok: true, verdict, note: `verdict from ${stampLabel(verdict.kj)}, checked by ${stampLabel(now)}` };
147
152
  }
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;