karajan-code 4.35.1 → 4.37.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 (48) 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/checks/rag-coverage.js +5 -4
  11. package/src/cli/advanced-commands.js +1 -1
  12. package/src/cli/register-meta.js +26 -4
  13. package/src/commands/board.js +20 -8
  14. package/src/commands/bootstrap.js +2 -0
  15. package/src/commands/env.js +8 -4
  16. package/src/commands/harden.js +26 -4
  17. package/src/commands/init.js +2 -1
  18. package/src/commands/plan/generate.js +4 -6
  19. package/src/commands/pr-size.js +45 -0
  20. package/src/commands/rag.js +69 -10
  21. package/src/commands/review-gate.js +47 -6
  22. package/src/commands/watch.js +13 -10
  23. package/src/config/defaults.js +3 -2
  24. package/src/environment/playbook.js +10 -3
  25. package/src/harden/guidelines-engine.js +24 -7
  26. package/src/harden/hook-templates.js +7 -0
  27. package/src/harden/phone-sign.js +28 -11
  28. package/src/harden/sentinel-hooks.js +82 -7
  29. package/src/lang/registry.js +2 -1
  30. package/src/mcp/handlers/rag-handler.js +14 -4
  31. package/src/orchestrator/drivers/post-loop.js +13 -11
  32. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
  33. package/src/orchestrator/stages/rag-context-stage.js +5 -2
  34. package/src/rag/auto-update.js +5 -3
  35. package/src/rag/coverage.js +13 -12
  36. package/src/rag/indexable.js +128 -0
  37. package/src/rag/indexer.js +31 -13
  38. package/src/rag/library.js +19 -13
  39. package/src/rag/migrate.js +73 -0
  40. package/src/rag/onnx-fallback.js +5 -3
  41. package/src/rag/project-store.js +48 -0
  42. package/src/rag/watcher.js +25 -14
  43. package/src/review/comment-only.js +93 -0
  44. package/src/review/kj-provenance.js +26 -0
  45. package/src/review/tests-with-code.js +5 -2
  46. package/src/review/verdict-store.js +6 -1
  47. package/src/start/sweep.js +4 -3
  48. 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 || ""));
@@ -278,9 +288,23 @@ process.stdin.on("end", () => {
278
288
  s.escapes.push(e);
279
289
  (state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
280
290
  }
291
+ // KJC-TSK-0910: the branch size while it is written, with the CI budget
292
+ // (kj pr-size), said once per threshold crossed. Context, never a block.
293
+ let sizeNote = null;
294
+ if (bucket) {
295
+ const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
296
+ let size = null;
297
+ try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
298
+ const crossed = size ? [200, 150].find((t) => size.added > t) : undefined;
299
+ if (crossed && (s.size_warned || 0) < crossed) {
300
+ s.size_warned = crossed;
301
+ sizeNote = "karajan sentinel: la rama ya suma " + size.added + " lineas contables (" + size.testAdded + " de tests); " + (crossed >= 200 ? "pasa el limite de 200 del CI: parte antes de seguir" : "pasa de 150: planea la particion ahora, no en el stage");
302
+ }
303
+ }
281
304
  const ids = Object.keys(state.sessions);
282
305
  if (ids.length > 5) delete state.sessions[ids.sort((a, b) => (state.sessions[a].at || 0) - (state.sessions[b].at || 0))[0]];
283
306
  save(state);
307
+ if (sizeNote) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: sizeNote } }));
284
308
  } catch { /* fail open — the sentinel never breaks a tool call */ }
285
309
  process.exit(0);
286
310
  });
@@ -980,9 +1004,19 @@ process.stdin.on("end", () => {
980
1004
  // cualquier proyecto sin indexar. Nada legible = se bloquea.
981
1005
  const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
982
1006
  let blind = null;
983
- try { const c = JSON.parse(String(cov.stdout || "")); if (c.state === "not-indexable") blind = c.reason; } catch { blind = null; }
1007
+ let absent = null;
1008
+ try {
1009
+ const c = JSON.parse(String(cov.stdout || ""));
1010
+ if (c.state === "not-indexable") blind = c.reason;
1011
+ // KJC-TSK-0891: si el indice no tiene el fichero, consultar no puede
1012
+ // devolverlo; el remedio es indexarlo, y se nombra.
1013
+ if (c.state === "stale" || c.state === "index-empty") absent = c.reason;
1014
+ } catch { blind = null; }
984
1015
  if (blind) {
985
1016
  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"));
1017
+ } else if (absent) {
1018
+ 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"));
1019
+ process.exit(2);
986
1020
  } else {
987
1021
  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
1022
  process.exit(2);
@@ -1159,7 +1193,33 @@ function sealedByPath(projectDir, gitShowFn) {
1159
1193
  }
1160
1194
 
1161
1195
  /**
1162
- * @returns {{ok: boolean, mismatched: string[], drift?: string[], reason?: string}}
1196
+ * KJC-BUG-0224: la procedencia PROPIA del harness, lo que `kj harden` escribio
1197
+ * (sha256 por guardia). Distinto del sello humano: no concede nada, solo dice
1198
+ * "esto lo puso kj", y lo unico que habilita es regenerar desde el kj
1199
+ * instalado. Forjarlo, por tanto, solo consigue que el guardia se restaure.
1200
+ */
1201
+ const INSTALLED_RECORD = "installed.json";
1202
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
1203
+
1204
+ function readInstalledRecord(dir) {
1205
+ try {
1206
+ const files = JSON.parse(readFileSync(join(dir, INSTALLED_RECORD), "utf8"))?.files;
1207
+ return files && typeof files === "object" ? files : {};
1208
+ } catch {
1209
+ return {};
1210
+ }
1211
+ }
1212
+
1213
+ function writeInstalledRecord(dir) {
1214
+ const files = {};
1215
+ for (const name of Object.keys(SCRIPT_BODIES)) {
1216
+ try { files[name] = sha256(readFileSync(join(dir, name), "utf8")); } catch { /* sin fichero no hay nada que registrar */ }
1217
+ }
1218
+ writeFileSync(join(dir, INSTALLED_RECORD), `${JSON.stringify({ files }, null, 2)}\n`);
1219
+ }
1220
+
1221
+ /**
1222
+ * @returns {{ok: boolean, mismatched: string[], drift?: string[], regenerated?: string[], reason?: string}}
1163
1223
  * `mismatched` is TAMPERING: a guard holds content nobody sealed, and that is
1164
1224
  * what the hooks block on. `drift` is a sealed guard whose templates have
1165
1225
  * moved on, which `kj harden` applies and which is not a defect.
@@ -1185,16 +1245,30 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1185
1245
  // cannot be borrowed by also editing a template: that was the hole in the
1186
1246
  // first attempt at this fix, and the review was right to reject it.
1187
1247
  const sealed = sealedByPath(root, gitShowFn);
1248
+ const ownRecord = readInstalledRecord(dir);
1188
1249
  const drift = [];
1189
1250
  const tampered = [];
1251
+ const regenerated = [];
1190
1252
  for (const name of mismatched) {
1191
1253
  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);
1254
+ const hash = text === undefined ? null : sha256(text);
1255
+ if (hash && sealed.get(`.karajan/harness/${name}`) === hash) drift.push(name);
1256
+ else if (hash && ownRecord[name] === hash) regenerated.push(name);
1194
1257
  else tampered.push(name);
1195
1258
  }
1196
- if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}) };
1259
+ // KJC-BUG-0224 caso 1: lo que kj escribio y nadie sello ni toco se pone al
1260
+ // dia solo. Es lo que hacia el ritual de `kj harden`, hecho por la maquina.
1261
+ if (regenerated.length > 0) {
1262
+ for (const name of regenerated) writeHarnessScript(root, name, SCRIPT_BODIES[name]);
1263
+ writeInstalledRecord(dir);
1264
+ }
1265
+ const done = regenerated.length ? { regenerated } : {};
1266
+ if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}), ...done };
1267
+ if (drift.length === 0) {
1268
+ return { ok: true, mismatched: [], ...done, reason: `kj avanzó y estos guardias eran los que kj escribió: regenerados (${regenerated.join(", ")})` };
1269
+ }
1197
1270
  return {
1271
+ ...done,
1198
1272
  ok: true,
1199
1273
  mismatched: [],
1200
1274
  drift,
@@ -1243,6 +1317,7 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1243
1317
  }
1244
1318
  return writeHarnessScript(projectDir, name, body);
1245
1319
  });
1320
+ writeInstalledRecord(join(projectDir, ".karajan", "harness"));
1246
1321
  if (deferred.length > 0) {
1247
1322
  logger?.info?.(`kj harden: ${deferred.join(", ")} se queda como está — es lo que un humano selló, y avanzarlo es suyo: kj harden --commit`);
1248
1323
  }
@@ -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";
@@ -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,8 +8,8 @@
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
13
  import { GENERATED_CONFIG_FILES } from "../harden/config-templates.js";
14
14
  import { getLastIndexedCommit, projectSlug } from "./vec-store.js";
15
15
 
@@ -36,11 +36,11 @@ export function isGeneratedByHarden(rel) {
36
36
  * @param {{db: object}} deps - an open vec store
37
37
  * @returns {Promise<{project: string, total: number, indexed: number, missing: string[], stale: string[], lastIndexedCommit: string|null, absent: boolean}>}
38
38
  */
39
- export async function ragIndexCoverage(projectDir, { db }) {
39
+ export async function ragIndexCoverage(projectDir, { db, exclude = [] }) {
40
40
  const project = projectSlug(projectDir);
41
- const matchers = buildMatchers(detectAdaptersForProject(projectDir));
42
41
  const { stdout } = await execa("git", ["-C", projectDir, "ls-files", "-z"]);
43
- const all = 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
44
  // Lo que kj genera se informa aparte: ni se exige indexado ni se esconde.
45
45
  const generated = all.filter(isGeneratedByHarden);
46
46
  const sources = all.filter((p) => !isGeneratedByHarden(p));
@@ -82,20 +82,21 @@ export async function ragIndexCoverage(projectDir, { db }) {
82
82
  *
83
83
  * @returns {{state: "indexed"|"stale"|"not-indexable"|"index-empty", rel: string, canAnswer: boolean, reason: string}}
84
84
  */
85
- export function fileIndexState(projectDir, file, { db }) {
85
+ export function fileIndexState(projectDir, file, { db, exclude = [] }) {
86
86
  const project = projectSlug(projectDir);
87
87
  // `./src/a.js` y `src/a.js` son el mismo fichero: sin normalizar, el segundo
88
88
  // salia "stale" estando indexado (catch de la review).
89
89
  const rel = normalize(isAbsolute(file) ? relative(projectDir, file) : file);
90
- const matchers = buildMatchers(detectAdaptersForProject(projectDir));
91
90
  const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE project_slug = ?").all(project);
92
91
  const indexed = new Set(rows.map((r) => normalize(isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
93
92
 
94
93
  if (indexed.has(rel)) return { state: "indexed", rel, canAnswer: true, reason: `the index holds chunks for ${rel}` };
95
94
  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` };
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}` };
101
102
  }
@@ -0,0 +1,128 @@
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, readFileSync, readSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { matchesAny } from "@karajan-family/governance";
15
+
16
+ import { buildMatchers, detectAdaptersForProject } from "../lang/registry.js";
17
+
18
+ const MAX_BYTES = 512 * 1024;
19
+ const SNIFF_BYTES = 8192;
20
+ // Generated by tools, not written by the team: noise in any language.
21
+ const GENERATED = [
22
+ /(^|\/)(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)$/,
23
+ /\.min\.(js|css)$/,
24
+ /\.map$/,
25
+ /\.snap$/,
26
+ ];
27
+
28
+ // May hold secrets: never sent to an embedder, which can be a remote service.
29
+ const SENSITIVE = [
30
+ /(^|\/)\.env(\.[^/]*)?$/,
31
+ /\.(pem|key|p12|pfx|jks|keystore)$/i,
32
+ /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
33
+ ];
34
+
35
+ // The skipped paths are the ones of THIS project's languages: Java's `bin/`
36
+ // is build output, a JS project's `bin/` is source. Cached per project.
37
+ const skipCache = new Map();
38
+ const skipsFor = (projectDir) => {
39
+ const key = projectDir ?? "";
40
+ if (!skipCache.has(key)) skipCache.set(key, buildMatchers(detectAdaptersForProject(projectDir ?? process.cwd())).shouldSkip);
41
+ return skipCache.get(key);
42
+ };
43
+
44
+ /**
45
+ * KJC-TSK-0900: `.ragignore` at the project root, gitignore syntax, versioned so
46
+ * the whole team inherits it. `dir/` is a folder anywhere, `*.csv` an extension
47
+ * anywhere, `/data` anchored to the root, a path with a slash is from the root.
48
+ * `!` negation is not supported (said in the docs). Re-read when it changes.
49
+ */
50
+ const ragignoreCache = new Map();
51
+ export function ragignoreGlobs(projectDir) {
52
+ if (!projectDir) return [];
53
+ const file = join(projectDir, ".ragignore");
54
+ let mtime;
55
+ try { mtime = statSync(file).mtimeMs; } catch { return []; }
56
+ const hit = ragignoreCache.get(file);
57
+ if (hit?.mtime === mtime) return hit.globs;
58
+ const globs = [];
59
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
60
+ let p = raw.trim();
61
+ if (!p || p.startsWith("#") || p.startsWith("!")) continue;
62
+ const dirOnly = p.endsWith("/");
63
+ p = p.replace(/^\/+|\/+$/g, "");
64
+ if (!p) continue;
65
+ const base = raw.trim().startsWith("/") || p.includes("/") ? p : `**/${p}`;
66
+ globs.push(`${base}/**`);
67
+ if (!dirOnly) globs.push(base);
68
+ }
69
+ ragignoreCache.set(file, { mtime, globs });
70
+ return globs;
71
+ }
72
+
73
+ /** What the project keeps out of its index: `rag.exclude`, repo-relative globs. */
74
+ export const ragExclude = (config) => (Array.isArray(config?.rag?.exclude) ? config.rag.exclude : []);
75
+
76
+ function looksBinary(abs) {
77
+ const fd = openSync(abs, "r");
78
+ try {
79
+ const buf = Buffer.alloc(SNIFF_BYTES);
80
+ const n = readSync(fd, buf, 0, SNIFF_BYTES, 0);
81
+ return buf.subarray(0, n).includes(0);
82
+ } finally {
83
+ closeSync(fd);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {string} rel path relative to the project root
89
+ * @param {string} abs absolute path
90
+ * @param {{exclude?: string[], skip?: (rel: string) => boolean}} [opts]
91
+ * @returns {string|null} why it stays out of the index, or null if it goes in
92
+ */
93
+ export function indexableReason(rel, abs, { exclude = [], projectDir = null, skip = skipsFor(projectDir) } = {}) {
94
+ if (skip(rel)) return `${rel} lives under a path the indexer always skips`;
95
+ if (matchesAny(rel, exclude)) return `${rel} is excluded by the project's rag.exclude`;
96
+ if (matchesAny(rel, ragignoreGlobs(projectDir))) return `${rel} is excluded by the project's .ragignore`;
97
+ if (SENSITIVE.some((re) => re.test(rel))) return `${rel} may hold secrets, and the embedder can be a remote service`;
98
+ if (GENERATED.some((re) => re.test(rel))) return `${rel} is a generated file (lockfile, minified, sourcemap or snapshot)`;
99
+ let st;
100
+ try { st = lstatSync(abs); } catch { return `${rel} does not exist`; }
101
+ // Never followed: a versioned symlink could point the embedder at any
102
+ // readable file outside the project, secrets included.
103
+ if (st.isSymbolicLink()) return `${rel} is a symbolic link`;
104
+ if (!st.isFile()) return `${rel} is not a regular file`;
105
+ if (st.size > MAX_BYTES) return `${rel} is larger than ${MAX_BYTES / 1024} KB`;
106
+ try {
107
+ if (looksBinary(abs)) return `${rel} is binary`;
108
+ } catch {
109
+ return `${rel} could not be read`;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * The project's files as git sees them: versioned, plus new ones git does not
116
+ * ignore. Null outside a git repo, so the caller can fall back to a walk.
117
+ * @returns {string[]|null} paths relative to projectDir
118
+ */
119
+ export function listProjectFiles(projectDir) {
120
+ try {
121
+ const out = execFileSync("git", ["-C", projectDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
122
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024,
123
+ });
124
+ return [...new Set(out.split("\0").filter(Boolean))];
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
@@ -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
  }