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
@@ -8,7 +8,7 @@
8
8
  * hooks, which is why the guaranteed level requires Claude as host (ADR).
9
9
  */
10
10
 
11
- import { readFileSync } from "node:fs";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import { join } from "node:path";
@@ -131,6 +131,7 @@ const POST_BODY = `#!/usr/bin/env node
131
131
  // kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
132
132
  // Records deterministic method facts per session; never blocks, never fails
133
133
  // a tool call (PostToolUse, always exit 0).
134
+ import console from "node:console";
134
135
  import process from "node:process";
135
136
  import { relative } from "node:path";
136
137
  import { spawnSync } from "node:child_process";
@@ -256,7 +257,16 @@ process.stdin.on("end", () => {
256
257
  const state = load();
257
258
  const s = session(state, sid);
258
259
  const ref = CARD.exec(headRef || branchOf() || "");
259
- (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
260
+ // KJC-BUG-0230: una EPICA (…-PCS-…) no tiene estado que mover: el PG
261
+ // la deja en To Do por mas status que reciba y kj hu move no la
262
+ // conoce. Registrarla como pendiente era un bloqueo sin cauce. Se dice
263
+ // en el momento, para que no parezca que el board dejo de mirar.
264
+ const epic = ref && /-PCS-/i.test(ref[0]);
265
+ if (epic) {
266
+ console.error("karajan sentinel: board-sync — la PR #" + mergedPr + " pertenece a la epica " + ref[0].toUpperCase() + ", que no tiene estado que mover: no queda pendiente (su cierre va en la descripcion de la epica)");
267
+ } else {
268
+ (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
269
+ }
260
270
  save(state);
261
271
  }
262
272
  const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
@@ -980,9 +990,19 @@ process.stdin.on("end", () => {
980
990
  // cualquier proyecto sin indexar. Nada legible = se bloquea.
981
991
  const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
982
992
  let blind = null;
983
- try { const c = JSON.parse(String(cov.stdout || "")); if (c.state === "not-indexable") blind = c.reason; } catch { blind = null; }
993
+ let absent = null;
994
+ try {
995
+ const c = JSON.parse(String(cov.stdout || ""));
996
+ if (c.state === "not-indexable") blind = c.reason;
997
+ // KJC-TSK-0891: si el indice no tiene el fichero, consultar no puede
998
+ // devolverlo; el remedio es indexarlo, y se nombra.
999
+ if (c.state === "stale" || c.state === "index-empty") absent = c.reason;
1000
+ } catch { blind = null; }
984
1001
  if (blind) {
985
1002
  console.error("karajan sentinel: rag-first — " + blind + " — no se te exige consultar lo que el indice no puede tener; el gate lo anota y sigue" + doc("rag-first"));
1003
+ } else if (absent) {
1004
+ console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
1005
+ process.exit(2);
986
1006
  } else {
987
1007
  console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
988
1008
  process.exit(2);
@@ -1159,7 +1179,33 @@ function sealedByPath(projectDir, gitShowFn) {
1159
1179
  }
1160
1180
 
1161
1181
  /**
1162
- * @returns {{ok: boolean, mismatched: string[], drift?: string[], reason?: string}}
1182
+ * KJC-BUG-0224: la procedencia PROPIA del harness, lo que `kj harden` escribio
1183
+ * (sha256 por guardia). Distinto del sello humano: no concede nada, solo dice
1184
+ * "esto lo puso kj", y lo unico que habilita es regenerar desde el kj
1185
+ * instalado. Forjarlo, por tanto, solo consigue que el guardia se restaure.
1186
+ */
1187
+ const INSTALLED_RECORD = "installed.json";
1188
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
1189
+
1190
+ function readInstalledRecord(dir) {
1191
+ try {
1192
+ const files = JSON.parse(readFileSync(join(dir, INSTALLED_RECORD), "utf8"))?.files;
1193
+ return files && typeof files === "object" ? files : {};
1194
+ } catch {
1195
+ return {};
1196
+ }
1197
+ }
1198
+
1199
+ function writeInstalledRecord(dir) {
1200
+ const files = {};
1201
+ for (const name of Object.keys(SCRIPT_BODIES)) {
1202
+ try { files[name] = sha256(readFileSync(join(dir, name), "utf8")); } catch { /* sin fichero no hay nada que registrar */ }
1203
+ }
1204
+ writeFileSync(join(dir, INSTALLED_RECORD), `${JSON.stringify({ files }, null, 2)}\n`);
1205
+ }
1206
+
1207
+ /**
1208
+ * @returns {{ok: boolean, mismatched: string[], drift?: string[], regenerated?: string[], reason?: string}}
1163
1209
  * `mismatched` is TAMPERING: a guard holds content nobody sealed, and that is
1164
1210
  * what the hooks block on. `drift` is a sealed guard whose templates have
1165
1211
  * moved on, which `kj harden` applies and which is not a defect.
@@ -1185,20 +1231,38 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1185
1231
  // cannot be borrowed by also editing a template: that was the hole in the
1186
1232
  // first attempt at this fix, and the review was right to reject it.
1187
1233
  const sealed = sealedByPath(root, gitShowFn);
1234
+ const ownRecord = readInstalledRecord(dir);
1188
1235
  const drift = [];
1189
1236
  const tampered = [];
1237
+ const regenerated = [];
1190
1238
  for (const name of mismatched) {
1191
1239
  const text = installed.get(name);
1192
- const sealedHash = sealed.get(`.karajan/harness/${name}`);
1193
- if (text !== undefined && sealedHash && sealedHash === createHash("sha256").update(text).digest("hex")) drift.push(name);
1240
+ const hash = text === undefined ? null : sha256(text);
1241
+ if (hash && sealed.get(`.karajan/harness/${name}`) === hash) drift.push(name);
1242
+ else if (hash && ownRecord[name] === hash) regenerated.push(name);
1194
1243
  else tampered.push(name);
1195
1244
  }
1196
- if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}) };
1245
+ // KJC-BUG-0224 caso 1: lo que kj escribio y nadie sello ni toco se pone al
1246
+ // dia solo. Es lo que hacia el ritual de `kj harden`, hecho por la maquina.
1247
+ if (regenerated.length > 0) {
1248
+ for (const name of regenerated) writeHarnessScript(root, name, SCRIPT_BODIES[name]);
1249
+ writeInstalledRecord(dir);
1250
+ }
1251
+ const done = regenerated.length ? { regenerated } : {};
1252
+ if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}), ...done };
1253
+ if (drift.length === 0) {
1254
+ return { ok: true, mismatched: [], ...done, reason: `kj avanzó y estos guardias eran los que kj escribió: regenerados (${regenerated.join(", ")})` };
1255
+ }
1197
1256
  return {
1257
+ ...done,
1198
1258
  ok: true,
1199
1259
  mismatched: [],
1200
1260
  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",
1261
+ // KJC-BUG-0222: `kj harden` NO pone al dia un guard sellado, lo deja en paz
1262
+ // a proposito (KJC-BUG-0193), asi que mandarlo era un bucle: ejecutas,
1263
+ // sale "unchanged" y el gate repite lo mismo. El que resuelve ESTE estado
1264
+ // es `kj harden --commit`, y es acto del humano.
1265
+ 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
1266
  };
1203
1267
  }
1204
1268
 
@@ -1214,6 +1278,13 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1214
1278
  * guard is still the right move.
1215
1279
  */
1216
1280
  export function installSentinelHooks({ projectDir = process.cwd(), logger = console, human = false, gitShowFn = null } = {}) {
1281
+ // KJC-BUG-0223: instalado desde un subdirectorio, esto sembraba un segundo
1282
+ // `.karajan/harness` ahi, y el gate (que mira otro sitio) pasaba a verlo. El
1283
+ // ritual quedaba "arreglado" y el arbol se llenaba de arneses huerfanos que
1284
+ // no gobiernan nada. El harness es del proyecto: va a su raiz.
1285
+ const root = resolveSentinelRoot(projectDir);
1286
+ if (root !== projectDir) logger?.info?.(`kj harden: el harness es del proyecto, se instala en su raíz (${root})`);
1287
+ projectDir = root;
1217
1288
  const sealed = human ? new Map() : sealedByPath(projectDir, gitShowFn);
1218
1289
  const deferred = [];
1219
1290
  const keepsItsSeal = (name) => {
@@ -1232,6 +1303,7 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1232
1303
  }
1233
1304
  return writeHarnessScript(projectDir, name, body);
1234
1305
  });
1306
+ writeInstalledRecord(join(projectDir, ".karajan", "harness"));
1235
1307
  if (deferred.length > 0) {
1236
1308
  logger?.info?.(`kj harden: ${deferred.join(", ")} se queda como está — es lo que un humano selló, y avanzarlo es suyo: kj harden --commit`);
1237
1309
  }
@@ -14,7 +14,8 @@ import { RUST_ADAPTER } from "./rust.js";
14
14
  // Segmentos siempre excluidos del walk, independientes de stack: VCS,
15
15
  // outputs de build, scratch de Karajan, sandbox del audit. Lo que viva
16
16
  // aquí jamás se indexa.
17
- const COMMON_SKIP_SEGMENTS = [".git", "dist", "build", "coverage", ".karajan", ".kj", "_diet"];
17
+ // KJC-BUG-0231: `.claude` guarda worktrees de subagentes, copias enteras del repo.
18
+ const COMMON_SKIP_SEGMENTS = [".git", "dist", "build", "coverage", ".karajan", ".kj", "_diet", ".claude"];
18
19
 
19
20
  const REGISTRY = [JS_ADAPTER, PYTHON_ADAPTER, RUST_ADAPTER, GO_ADAPTER, JAVA_ADAPTER];
20
21
 
@@ -1,9 +1,13 @@
1
1
  // KJC-PCS-0049 Step 7 — MCP handlers calling src/rag/* directly. Imports
2
2
  // from src/commands/* are forbidden by the layer-boundaries test
3
3
  // (MCP and CLI are peer layers); we hit the pure rag/* modules instead.
4
- import { countChunks, openVecStore, projectSlug } from "../../rag/vec-store.js";
4
+ import { join } from "node:path";
5
+ import { countChunks, projectSlug } from "../../rag/vec-store.js";
6
+ import { openProjectStore } from "../../rag/project-store.js";
7
+ import { countProjectChunks, emptyIndexRemedy } from "../../rag/migrate.js";
5
8
  import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
6
9
  import { indexProject } from "../../rag/indexer.js";
10
+ import { ragExclude } from "../../rag/indexable.js";
7
11
  import { query } from "../../rag/retriever.js";
8
12
  import { getKarajanHome } from "../../utils/paths.js";
9
13
  import { resolveProjectDir, buildConfig, responseText, failPayload } from "../shared-helpers.js";
@@ -28,9 +32,14 @@ export async function handleRagQuery(args, server) {
28
32
  // projectDir, `project: "all"` disables the filter, `project: <slug>`
29
33
  // overrides it.
30
34
  const project = args?.project === "all" ? null : (args?.project || projectSlug(projectDir) || null);
31
- const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
35
+ const dim = config?.rag?.embedder?.dim || 768;
36
+ // KJC-TSK-0883 (ADR 0011): el indice del proyecto, como la CLI (0882).
37
+ const db = openProjectStore({ projectDir, dim });
32
38
  try {
33
- if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope, project });
39
+ if (countChunks(db) === 0 || (project && countProjectChunks(db, project) === 0)) {
40
+ const remedy = emptyIndexRemedy({ slug: project, legacyPath: join(getKarajanHome(), "rag.db"), dim });
41
+ return responseText({ hits: [], empty: true, topK, scope, project, remedy });
42
+ }
34
43
  const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project });
35
44
  return responseText({ hits, empty: false, topK, scope, project });
36
45
  } finally { db.close(); }
@@ -43,12 +52,13 @@ export async function handleRagIndex(args, server) {
43
52
  try {
44
53
  const projectDir = await resolveProjectDir(server, args?.projectDir);
45
54
  const config = await buildConfig({ ...args, projectDir }, "rag-index");
46
- const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
55
+ const db = openProjectStore({ projectDir, dim: config?.rag?.embedder?.dim || 768 });
47
56
  try {
48
57
  const totals = await indexProject(projectDir, {
49
58
  db, embedder: makeGovernedEmbedder(config),
50
59
  karajanHome: getKarajanHome(), logger: silentLogger,
51
60
  withSources: Boolean(args?.withSources),
61
+ exclude: ragExclude(config), // KJC-TSK-0891
52
62
  });
53
63
  return responseText(totals);
54
64
  } finally { db.close(); }
@@ -24,7 +24,7 @@
24
24
  import { generateDiff } from "../../review/diff-generator.js";
25
25
  import { finalizeGitAutomation } from "../../git/automation.js";
26
26
  import { listCommitsBetween, listFilesChangedSince } from "../../utils/git.js";
27
- import { loadPlan, projectSlug as slugFor } from "../../plan/plan-store.js";
27
+ import { loadPlan } from "../../plan/plan-store.js";
28
28
  import { computePlanAdherenceScore } from "../../audit/plan-adherence.js";
29
29
  import { saveSession, markSessionStatus } from "../../session/store.js";
30
30
  import {
@@ -334,22 +334,24 @@ export async function handleMaxIterationsReached({ session, budgetSummary, emitt
334
334
  return { approved: false, sessionId: session.id, reason: "max_iterations" };
335
335
  }
336
336
 
337
+ /**
338
+ * KJC-TSK-0884 (ADR 0011): the board starts by itself, like Sonar. Only a
339
+ * declared false on either switch opts out; nothing declared means on.
340
+ */
341
+ export const boardAutoStartWanted = (config) => config?.hu_board?.auto_start !== false && config?.hu_board?.enabled !== false;
342
+
337
343
  export async function tryAutoStartBoard(config, logger, emitter, eventBase) {
338
- // TSK-0273: gate on hu_board.auto_start alone. The previous double-gate
339
- // (enabled && auto_start) was confusing because `enabled` was never the
340
- // user-facing switch — only auto_start was. This matches the AC: "kj run
341
- // auto-inicia el board server si hu_board.auto_start es true".
342
- if (!config.hu_board?.auto_start) return;
344
+ if (!boardAutoStartWanted(config)) return;
343
345
  // Never auto-start during vitest: races the PID file and starts a detached
344
346
  // process that outlives the test run.
345
347
  if (process.env.VITEST || process.env.NODE_ENV === "test") return;
346
348
 
347
349
  try {
348
- const { startBoard, renderBoardBanner } = await import("../../commands/board.js");
349
- const boardPort = config.hu_board.port || 4000;
350
- // Scope the board URL to the current run's project (`#board/<slug>`)
351
- // so the user lands on a filtered view, not the global dashboard.
352
- const slug = config.projectDir ? slugFor(config.projectDir) : null;
350
+ const { startBoard, renderBoardBanner, boardSlug } = await import("../../commands/board.js");
351
+ const boardPort = config.hu_board?.port || 4000;
352
+ // KJC-TSK-0884: the project's scoped view (/p/<slug>), the parent repo's
353
+ // even from a worktree lane.
354
+ const slug = config.projectDir ? boardSlug(config.projectDir) : null;
353
355
  const boardResult = await startBoard(boardPort, { projectSlug: slug });
354
356
  const status = boardResult.alreadyRunning ? "already running" : "started";
355
357
  // Highlighted URL box — visible regardless of log level (matches the
@@ -122,9 +122,10 @@ export async function maybeGenerateAutoHuBatch({
122
122
  // node process around after the suite (TSK-0273).
123
123
  if (process.env.VITEST || process.env.NODE_ENV === "test") return;
124
124
  try {
125
- const { startBoard, renderBoardBanner } = await import("../../../commands/board.js");
125
+ const { startBoard, renderBoardBanner, boardSlug } = await import("../../../commands/board.js");
126
126
  const desiredPort = session.config_snapshot?.hu_board?.port ?? 4000;
127
- const boardResult = await startBoard(desiredPort);
127
+ // KJC-TSK-0884: its project's scoped view, not the global dashboard.
128
+ const boardResult = await startBoard(desiredPort, { projectSlug: projectDir ? boardSlug(projectDir) : null });
128
129
  const url = boardResult.url;
129
130
  const status = boardResult.alreadyRunning ? "already running" : "started";
130
131
  const projectName = batch.projectName || "Auto-generated HUs";
@@ -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}`
@@ -5,9 +5,10 @@
5
5
  // Failures degrade gracefully (warn + continue); the rest of the pipeline
6
6
  // never sees an exception from this stage.
7
7
  import { emitProgress, makeEvent } from "../../utils/events.js";
8
- import { openVecStore, countChunks } from "../../rag/vec-store.js";
8
+ import { countChunks } from "../../rag/vec-store.js";
9
9
  import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
10
10
  import { query } from "../../rag/retriever.js";
11
+ import { openProjectStore } from "../../rag/project-store.js";
11
12
 
12
13
  const DEFAULT_TOP_K = 5;
13
14
  const DEFAULT_SCOPE = "all";
@@ -44,7 +45,9 @@ export async function runRagContextStage({ config, logger, emitter, eventBase, t
44
45
  if (typeof task !== "string" || task.length === 0) return { skipped: true, reason: "no-task" };
45
46
  try {
46
47
  const dim = config?.rag?.embedder?.dim || 768;
47
- const db = openVecStore({ dim });
48
+ // KJC-TSK-0882: el preload que entra en el prompt del coder solo puede traer
49
+ // codigo de ESTE proyecto: antes abria el indice de la maquina sin filtrar.
50
+ const db = openProjectStore({ projectDir: config?.projectDir || process.cwd(), dim });
48
51
  try {
49
52
  if (countChunks(db) === 0) {
50
53
  logger?.info?.("[rag-preload] corpus is empty, skipping (run `kj rag index` to seed)");
@@ -6,9 +6,11 @@ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { execa } from "execa";
8
8
 
9
- import { openVecStore, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
9
+ import { projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
10
10
  import { indexProjectDelta } from "./indexer.js";
11
+ import { ragExclude } from "./indexable.js";
11
12
  import { makeGovernedEmbedder } from "./governed-embedder.js";
13
+ import { openProjectStore } from "./project-store.js";
12
14
 
13
15
  const HOOK_SRC = resolve(fileURLToPath(import.meta.url), "../../../scripts/git-hooks/post-merge");
14
16
 
@@ -20,12 +22,12 @@ export async function maybeAutoUpdate({ projectDir, config, logger = console, fl
20
22
  try { const r = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]); head = r.stdout.trim(); }
21
23
  catch { return { skipped: true }; }
22
24
  const slug = projectSlug(projectDir);
23
- const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
25
+ const db = openProjectStore({ projectDir, dim: config?.rag?.embedder?.dim || 768 });
24
26
  try {
25
27
  const since = getLastIndexedCommit(db, slug);
26
28
  if (!since || since === head) return { skipped: true, head };
27
29
  logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}); running delta update`);
28
- const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger });
30
+ const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger, exclude: ragExclude(config) });
29
31
  if (totals.head) setLastIndexedCommit(db, slug, totals.head);
30
32
  return { ran: true, totals };
31
33
  } catch (err) {
@@ -8,20 +8,42 @@
8
8
  * matchers are the single truth, here and in the indexer.
9
9
  */
10
10
  import { execa } from "execa";
11
- import { extname, isAbsolute, normalize, relative } from "node:path";
12
- import { detectAdaptersForProject, buildMatchers } from "../lang/registry.js";
11
+ import { isAbsolute, join, normalize, relative } from "node:path";
12
+ import { indexableReason } from "./indexable.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
18
37
  * @returns {Promise<{project: string, total: number, indexed: number, missing: string[], stale: string[], lastIndexedCommit: string|null, absent: boolean}>}
19
38
  */
20
- export async function ragIndexCoverage(projectDir, { db }) {
39
+ export async function ragIndexCoverage(projectDir, { db, exclude = [] }) {
21
40
  const project = projectSlug(projectDir);
22
- const matchers = buildMatchers(detectAdaptersForProject(projectDir));
23
41
  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));
42
+ // KJC-TSK-0891: the indexer's own criterion, not a list of extensions.
43
+ const all = stdout.split("\0").filter((p) => p && indexableReason(p, join(projectDir, p), { exclude, projectDir }) === null);
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
  /**
@@ -60,20 +82,21 @@ export async function ragIndexCoverage(projectDir, { db }) {
60
82
  *
61
83
  * @returns {{state: "indexed"|"stale"|"not-indexable"|"index-empty", rel: string, canAnswer: boolean, reason: string}}
62
84
  */
63
- export function fileIndexState(projectDir, file, { db }) {
85
+ export function fileIndexState(projectDir, file, { db, exclude = [] }) {
64
86
  const project = projectSlug(projectDir);
65
87
  // `./src/a.js` y `src/a.js` son el mismo fichero: sin normalizar, el segundo
66
88
  // salia "stale" estando indexado (catch de la review).
67
89
  const rel = normalize(isAbsolute(file) ? relative(projectDir, file) : file);
68
- const matchers = buildMatchers(detectAdaptersForProject(projectDir));
69
90
  const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE project_slug = ?").all(project);
70
91
  const indexed = new Set(rows.map((r) => normalize(isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
71
92
 
72
93
  if (indexed.has(rel)) return { state: "indexed", rel, canAnswer: true, reason: `the index holds chunks for ${rel}` };
73
94
  if (indexed.size === 0) return { state: "index-empty", rel, canAnswer: false, reason: `nothing of this project is indexed yet: kj rag index` };
74
- if (matchers.shouldSkip(rel)) return { state: "not-indexable", rel, canAnswer: false, reason: `${rel} lives under a path the indexer always skips` };
75
- if (!matchers.isCodeFile(rel) && extname(rel).toLowerCase() !== ".md") {
76
- 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}` };
77
- }
78
- return { state: "stale", rel, canAnswer: false, reason: `${rel} is indexable but absent from the index: kj rag index --since auto` };
95
+ // KJC-TSK-0891: not-indexable only for the file's NATURE (binary, generated,
96
+ // huge, excluded, skipped path), never for its language.
97
+ const out = indexableReason(rel, join(projectDir, rel), { exclude, projectDir });
98
+ if (out) return { state: "not-indexable", rel, canAnswer: false, reason: out };
99
+ // KJC-TSK-0891: `--since auto` only reaches what changed, so it cannot fix an
100
+ // unchanged file the index never held; indexing THIS file does.
101
+ return { state: "stale", rel, canAnswer: false, reason: `${rel} is indexable but absent from the index: kj rag index --file ${rel}` };
79
102
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * KJC-TSK-0891 (#1807 point 4) — what the index takes, decided without a list
3
+ * of extensions: there are countless languages, and a list always falls short.
4
+ * Any text file the project versions is indexable. What stays out stays out for
5
+ * its NATURE, never its language: binary, generated, too large, excluded by the
6
+ * project, or under a path the indexer always skips. Where a language adapter
7
+ * exists the chunker splits by symbol; elsewhere it falls back to overlapping
8
+ * text windows (chunkSource already does).
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { closeSync, lstatSync, openSync, readSync } from "node:fs";
12
+
13
+ import { matchesAny } from "@karajan-family/governance";
14
+
15
+ import { buildMatchers, detectAdaptersForProject } from "../lang/registry.js";
16
+
17
+ const MAX_BYTES = 512 * 1024;
18
+ const SNIFF_BYTES = 8192;
19
+ // Generated by tools, not written by the team: noise in any language.
20
+ const GENERATED = [
21
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|Pipfile\.lock|uv\.lock|composer\.lock|Gemfile\.lock|go\.sum)$/,
22
+ /\.min\.(js|css)$/,
23
+ /\.map$/,
24
+ /\.snap$/,
25
+ ];
26
+
27
+ // May hold secrets: never sent to an embedder, which can be a remote service.
28
+ const SENSITIVE = [
29
+ /(^|\/)\.env(\.[^/]*)?$/,
30
+ /\.(pem|key|p12|pfx|jks|keystore)$/i,
31
+ /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
32
+ ];
33
+
34
+ // The skipped paths are the ones of THIS project's languages: Java's `bin/`
35
+ // is build output, a JS project's `bin/` is source. Cached per project.
36
+ const skipCache = new Map();
37
+ const skipsFor = (projectDir) => {
38
+ const key = projectDir ?? "";
39
+ if (!skipCache.has(key)) skipCache.set(key, buildMatchers(detectAdaptersForProject(projectDir ?? process.cwd())).shouldSkip);
40
+ return skipCache.get(key);
41
+ };
42
+
43
+ /** What the project keeps out of its index: `rag.exclude`, repo-relative globs. */
44
+ export const ragExclude = (config) => (Array.isArray(config?.rag?.exclude) ? config.rag.exclude : []);
45
+
46
+ function looksBinary(abs) {
47
+ const fd = openSync(abs, "r");
48
+ try {
49
+ const buf = Buffer.alloc(SNIFF_BYTES);
50
+ const n = readSync(fd, buf, 0, SNIFF_BYTES, 0);
51
+ return buf.subarray(0, n).includes(0);
52
+ } finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {string} rel path relative to the project root
59
+ * @param {string} abs absolute path
60
+ * @param {{exclude?: string[], skip?: (rel: string) => boolean}} [opts]
61
+ * @returns {string|null} why it stays out of the index, or null if it goes in
62
+ */
63
+ export function indexableReason(rel, abs, { exclude = [], projectDir = null, skip = skipsFor(projectDir) } = {}) {
64
+ if (skip(rel)) return `${rel} lives under a path the indexer always skips`;
65
+ if (matchesAny(rel, exclude)) return `${rel} is excluded by the project's rag.exclude`;
66
+ if (SENSITIVE.some((re) => re.test(rel))) return `${rel} may hold secrets, and the embedder can be a remote service`;
67
+ if (GENERATED.some((re) => re.test(rel))) return `${rel} is a generated file (lockfile, minified, sourcemap or snapshot)`;
68
+ let st;
69
+ try { st = lstatSync(abs); } catch { return `${rel} does not exist`; }
70
+ // Never followed: a versioned symlink could point the embedder at any
71
+ // readable file outside the project, secrets included.
72
+ if (st.isSymbolicLink()) return `${rel} is a symbolic link`;
73
+ if (!st.isFile()) return `${rel} is not a regular file`;
74
+ if (st.size > MAX_BYTES) return `${rel} is larger than ${MAX_BYTES / 1024} KB`;
75
+ try {
76
+ if (looksBinary(abs)) return `${rel} is binary`;
77
+ } catch {
78
+ return `${rel} could not be read`;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * The project's files as git sees them: versioned, plus new ones git does not
85
+ * ignore. Null outside a git repo, so the caller can fall back to a walk.
86
+ * @returns {string[]|null} paths relative to projectDir
87
+ */
88
+ export function listProjectFiles(projectDir) {
89
+ try {
90
+ const out = execFileSync("git", ["-C", projectDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
91
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024,
92
+ });
93
+ return [...new Set(out.split("\0").filter(Boolean))];
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
@@ -12,7 +12,8 @@ import { execa } from "execa";
12
12
 
13
13
  import { chunkMarkdown, chunkPlan, chunkSource } from "./chunker.js";
14
14
  import { insertChunk, deleteChunksBySource, findChunkByHash } from "./vec-store.js";
15
- import { detectAdaptersForProject, buildMatchers, getAllCodeExtensions } from "../lang/registry.js";
15
+ import { detectAdaptersForProject, getAllCodeExtensions } from "../lang/registry.js";
16
+ import { indexableReason, listProjectFiles } from "./indexable.js";
16
17
 
17
18
  // KJC-PCS-0052 PR-A — antes vivían dos consts hard-coded para JS aquí
18
19
  // (CODE_EXT_RE + SKIP_SEGMENTS). Ahora vienen del registry de adapters,
@@ -39,19 +40,28 @@ function chunksFor(path, kind) {
39
40
  if (kind === "plan" || kind === "onboarding") {
40
41
  return chunkMarkdown(text, { path, kind });
41
42
  }
43
+ // KJC-TSK-0891: a project's own prose is still a project source ("code":
44
+ // the store's schema allows no other kind), split by headings.
45
+ if (kind === "code" && /\.mdx?$/i.test(path)) return chunkMarkdown(text, { path, kind });
42
46
  if (kind === "code") return chunkSource(text, { path });
43
47
  return [];
44
48
  }
45
49
 
50
+ /**
51
+ * KJC-TSK-0891: every project source is indexed as "code". Where an adapter
52
+ * exists it splits by symbol; any other text falls back to overlapping windows.
53
+ */
54
+ export const sourceKind = () => "code";
55
+
46
56
  /**
47
57
  * Index a single file. Returns `{ indexed, failed }`. Idempotent —
48
58
  * pre-existing chunks for this `source` path are deleted before the
49
59
  * fresh batch is written, so a second call against the same file
50
60
  * leaves the store with exactly the latest chunks (no duplicates).
51
61
  */
52
- export async function indexFile(path, { db, embedder, logger = console, project = null } = {}) {
62
+ export async function indexFile(path, { db, embedder, logger = console, project = null, kind: declaredKind = null } = {}) {
53
63
  if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
54
- const kind = detectKind(path);
64
+ const kind = declaredKind ?? detectKind(path);
55
65
  if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
56
66
  const chunks = chunksFor(path, kind);
57
67
  if (!chunks.length) return { indexed: 0, failed: 0, skipped: 0 };
@@ -118,7 +128,7 @@ async function listFiles(dir, predicate) {
118
128
  * indexing tens of thousands of files is expensive — the CLI exposes
119
129
  * it as `--with-sources`.
120
130
  */
121
- export async function indexProject(projectDir, { db, embedder, karajanHome, logger = console, withSources = false } = {}) {
131
+ export async function indexProject(projectDir, { db, embedder, karajanHome, logger = console, withSources = false, exclude = [] } = {}) {
122
132
  // KJC-TSK-0640: a full index must stamp HEAD too — without it,
123
133
  // last_indexed_commit stayed null after the FIRST index, so the drift
124
134
  // delta-update (maybeAutoUpdate) never engaged until a manual --since.
@@ -148,10 +158,20 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
148
158
  // duplicate every chunk and contaminate retrieval scores. `_diet` is the
149
159
  // tests/_diet/ sandbox used by the test-diet audit harness — never user
150
160
  // code. Both viven en COMMON_SKIP_SEGMENTS dentro del registry.
151
- const matchers = buildMatchers(detectAdaptersForProject(projectDir));
152
- const sources = await listFiles(projectDir, (p) => matchers.isCodeFile(p) && !matchers.shouldSkip(p));
161
+ // KJC-BUG-0231: las exclusiones se juzgan DENTRO del proyecto (ruta relativa).
162
+ // KJC-TSK-0891: sin lista de extensiones: cualquier texto que el proyecto
163
+ // versiona, fuera lo binario, generado, enorme o excluido (indexable.js).
164
+ const outOf = (abs) => indexableReason(relative(projectDir, abs), abs, { exclude, projectDir });
165
+ const listed = listProjectFiles(projectDir);
166
+ const candidates = listed ? listed.map((rel) => join(projectDir, rel)) : await listFiles(projectDir, () => true);
167
+ const sources = candidates.filter((abs) => outOf(abs) === null);
168
+ // Lo que un indice anterior guardo y hoy ya no entra (excluido o borrado), fuera.
169
+ const prefix = `${projectDir}/`;
170
+ for (const { source } of db.prepare("SELECT DISTINCT source FROM chunks WHERE project_slug = ?").all(slug)) {
171
+ if (source.startsWith(prefix) && outOf(source) !== null) deleteChunksBySource(db, source);
172
+ }
153
173
  for (const s of sources) {
154
- const r = await indexFile(s, { db, embedder, logger, project: slug });
174
+ const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind(s) });
155
175
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
156
176
  }
157
177
  }
@@ -165,7 +185,7 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
165
185
  * `head` lets callers persist via setLastIndexedCommit. Throws on diff failure
166
186
  * (shallow clone, unknown ref) so the CLI can fall back to a full reindex.
167
187
  */
168
- export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console } = {}) {
188
+ export async function indexProjectDelta(projectDir, { db, embedder, since, logger = console, exclude = [] } = {}) {
169
189
  const totals = { indexed: 0, failed: 0, files: 0, deleted: 0, head: null };
170
190
  const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
171
191
  const { stdout: head } = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]);
@@ -182,16 +202,14 @@ export async function indexProjectDelta(projectDir, { db, embedder, since, logge
182
202
  }
183
203
  const adapters = detectAdaptersForProject(projectDir);
184
204
  await prepareAdapters(adapters, { logger });
185
- const matchers = buildMatchers(adapters);
186
205
  for (const { kind, p } of ops) {
187
- if (matchers.shouldSkip(p)) continue;
188
- if (!matchers.isCodeFile(p)) continue;
189
206
  const abs = isAbsolute(p) ? p : join(projectDir, p);
190
207
  if (kind === "del") {
191
208
  totals.deleted += deleteChunksBySource(db, abs);
192
209
  logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
193
- } else if (existsSync(abs)) {
194
- const r = await indexFile(abs, { db, embedder, logger, project: slug });
210
+ } else if (indexableReason(relative(projectDir, abs), abs, { exclude, projectDir }) === null) {
211
+ // KJC-TSK-0891: the same criterion as the full walk, not a list of extensions.
212
+ const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(abs) });
195
213
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
196
214
  }
197
215
  }