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