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.
- package/package.json +1 -1
- package/packages/hu-board/public/utils/board-view.js +2 -1
- package/packages/hu-board/public/utils/config-editor.js +5 -2
- package/packages/hu-board/public/utils/terminal-panel.js +2 -1
- package/packages/hu-board/src/config-yaml.js +11 -9
- package/packages/hu-board/src/project-dir.js +42 -0
- package/packages/hu-board/src/routes/api.js +11 -3
- package/packages/hu-board/src/terminal-wire.js +5 -1
- package/packages/hu-board/src/terminal.js +8 -3
- package/src/checks/rag-coverage.js +5 -4
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +26 -4
- package/src/commands/board.js +20 -8
- package/src/commands/bootstrap.js +2 -0
- package/src/commands/env.js +8 -4
- package/src/commands/harden.js +26 -4
- package/src/commands/init.js +2 -1
- package/src/commands/plan/generate.js +4 -6
- package/src/commands/pr-size.js +45 -0
- package/src/commands/rag.js +69 -10
- package/src/commands/review-gate.js +47 -6
- package/src/commands/watch.js +13 -10
- package/src/config/defaults.js +3 -2
- package/src/environment/playbook.js +10 -3
- package/src/harden/guidelines-engine.js +24 -7
- package/src/harden/hook-templates.js +7 -0
- package/src/harden/phone-sign.js +28 -11
- package/src/harden/sentinel-hooks.js +82 -7
- package/src/lang/registry.js +2 -1
- package/src/mcp/handlers/rag-handler.js +14 -4
- package/src/orchestrator/drivers/post-loop.js +13 -11
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
- package/src/orchestrator/stages/rag-context-stage.js +5 -2
- package/src/rag/auto-update.js +5 -3
- package/src/rag/coverage.js +13 -12
- package/src/rag/indexable.js +128 -0
- package/src/rag/indexer.js +31 -13
- package/src/rag/library.js +19 -13
- package/src/rag/migrate.js +73 -0
- package/src/rag/onnx-fallback.js +5 -3
- package/src/rag/project-store.js +48 -0
- package/src/rag/watcher.js +25 -14
- package/src/review/comment-only.js +93 -0
- package/src/review/kj-provenance.js +26 -0
- package/src/review/tests-with-code.js +5 -2
- package/src/review/verdict-store.js +6 -1
- package/src/start/sweep.js +4 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
1193
|
-
if (
|
|
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
|
-
|
|
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
|
}
|
package/src/lang/registry.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
const slug = config.projectDir ?
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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)");
|
package/src/rag/auto-update.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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) {
|
package/src/rag/coverage.js
CHANGED
|
@@ -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 {
|
|
12
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
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
|
+
}
|
package/src/rag/indexer.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
152
|
-
|
|
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 (
|
|
194
|
-
|
|
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
|
}
|