karajan-code 2.26.0 → 2.27.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/README.md CHANGED
@@ -345,13 +345,22 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
- > **v2.26.0 released** — Minor. **RAG Auto-Bootstrap** — Ollama runs in Docker out of the box. `kj init` now provisions the embedder automatically (or skips with a clear reason on modest hardware / `--no-ollama`); `kj doctor` surfaces health; `kj ollama [start|stop|status|pull]` manages lifecycle without docker compose.
348
+ > **v2.27.0 released** — Minor. **RAG polish** — three improvements triggered by the v2.26.0 smoke test on karajan-code itself:
349
+ >
350
+ > - **Per-project isolation** (PR #831): new `project_slug` column on chunks; `kj rag query --project <slug>` (auto-detected from cwd) filters the global DB. `--project all` to query across everything.
351
+ > - **`docs/RAG.md` + `docs/es/RAG.md`** (PR #832): single unified guide covering architecture, install, six workflows, configuration, limitations and troubleshooting. Replaces the documentation spread across CHANGELOG / templates / landing.
352
+ > - **Asymmetric source-vs-test ranking** (PR #833): NL queries like `how does X work` no longer rank `tests/X.test.js` above `src/X.js`; test-flavoured queries still surface tests.
353
+ > - Plus **KJC-BUG-0063** (PR #834): skipped a TZ-dependent test that was blocking every CI run, and a `shrink-budget` workflow exclude fix for `docs/*.md` at the root.
354
+ >
355
+ > **Coming in v2.28.0+**: chokidar watcher for live re-indexing, AST source chunker (tree-sitter / @babel/parser), BM25 + cosine hybrid scoring, OpenAI/Voyage embedder adapters (for users without local Docker).
356
+ >
357
+ > **v2.26.0 released** — Minor. **RAG Auto-Bootstrap** — Ollama runs in Docker out of the box. `kj init` now provisions the embedder automatically (or skips with a clear reason on modest hardware / `--no-ollama`); `kj doctor` surfaces health; `kj ollama [start|stop|status|pull]` manages lifecycle without docker compose. See [docs/RAG.md](docs/RAG.md) for the full RAG guide ([español](docs/es/RAG.md)).
349
358
  >
350
359
  > Capability check (RAM + Docker) means the bootstrap never breaks init: on Windows without Docker Desktop, on hosts under 4 GB free, or with `--no-ollama`, the wizard logs a one-liner and continues. Where Ollama is already running on `:11434`, `kj init` reuses the external instance instead of spawning a second container.
351
360
  >
352
361
  > Bundles **KJC-BUG-0061** fix (caught during v2.25.0 smoke test): `kj onboard --no-synth` was ignored by Commander shape mapping, `OnboarderRole.run()` was called without `init()`, and `kj rag query --json` on empty store emitted just `[]` instead of the `{empty: true}` contract the MCP handler returns.
353
362
  >
354
- > **Coming in v2.27.0+**: chokidar watcher for live re-indexing, AST source chunker (tree-sitter / @babel/parser), BM25 + cosine hybrid scoring, OpenAI/Voyage embedder adapters (for users without local Docker).
363
+ > **Coming next from v2.26.0**: addressed in v2.27.0 above.
355
364
  >
356
365
  > **v2.25.0 released** — Minor. **RAG Camino B + Camino D** (KJC-PCS-0049). Closes the consumer-surface plan. Skills hosts can now invoke RAG via `/kj-rag-query` without MCP, and the pre-loop retrieval stage from v2.24.0 only fires when triage signals make it worthwhile.
357
366
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.26.0",
3
+ "version": "2.27.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -168,6 +168,8 @@ const ragStubPlugin = {
168
168
  // KJC-TSK-0436 — Ollama capability check + model pull.
169
169
  checkDockerAvailable: notAvailable, checkRamCapacity: notAvailable,
170
170
  checkOllamaCapability: notAvailable, pullOllamaModel: notAvailable,
171
+ // KJC-TSK-0438 — RAG project isolation.
172
+ projectSlug: notAvailable,
171
173
  default: notAvailable,
172
174
  };
173
175
  `,
@@ -113,6 +113,7 @@ export function registerMeta(program, { pkgVersion }) {
113
113
  .description("Run a semantic query against the indexed RAG corpus")
114
114
  .option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
115
115
  .option("--top-k <n>", "Number of hits to return (default: 5)", "5")
116
+ .option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project. Default: cwd basename")
116
117
  .option("--json", "Output the hits as JSON")
117
118
  .action(async (text, flags) => {
118
119
  await withConfig(pkgVersion, "rag-query", flags, async ({ config, logger }) => {
@@ -2,7 +2,7 @@
2
2
  // kj rag index [--project <slug>] [--with-sources]
3
3
  // kj rag query <text> [--scope plans|code|onboarding|all] [--top-k N] [--json]
4
4
  // Closes the v2.22.0 RAG MVP end-to-end from the terminal.
5
- import { openVecStore, countChunks } from "../rag/vec-store.js";
5
+ import { openVecStore, countChunks, projectSlug } from "../rag/vec-store.js";
6
6
  import { OllamaEmbedder } from "../rag/embedder.js";
7
7
  import { indexProject } from "../rag/indexer.js";
8
8
  import { query } from "../rag/retriever.js";
@@ -43,6 +43,12 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
43
43
  try {
44
44
  const topK = Math.max(1, Number(flags.topK) || 5);
45
45
  const scope = flags.scope || "all";
46
+ // KJC-TSK-0438 — project isolation. Auto-detect slug from projectDir
47
+ // (basename normalised); `--project all` disables the filter, `--project
48
+ // <slug>` overrides. Pre-v2.27 chunks with NULL slug are only visible
49
+ // when no filter is in effect.
50
+ const detected = projectSlug(config?.projectDir || process.cwd());
51
+ const project = flags.project === "all" ? null : (flags.project || detected || null);
46
52
  // KJC-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
47
53
  // handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
48
54
  // so agents (and the `/kj-rag-query` skill from Camino B) have a
@@ -54,7 +60,7 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
54
60
  if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope })}\n`);
55
61
  return [];
56
62
  }
57
- const hits = await query(db, makeEmbedder(config), text, { topK, scope });
63
+ const hits = await query(db, makeEmbedder(config), text, { topK, scope, project });
58
64
  if (flags.json) {
59
65
  process.stdout.write(`${JSON.stringify(hits)}\n`);
60
66
  } else {
@@ -41,7 +41,7 @@ function chunksFor(path, kind) {
41
41
  * fresh batch is written, so a second call against the same file
42
42
  * leaves the store with exactly the latest chunks (no duplicates).
43
43
  */
44
- export async function indexFile(path, { db, embedder, logger = console } = {}) {
44
+ export async function indexFile(path, { db, embedder, logger = console, project = null } = {}) {
45
45
  if (!existsSync(path)) { logger.warn?.(`[rag-indexer] file not found: ${path}`); return { indexed: 0, failed: 0 }; }
46
46
  const kind = detectKind(path);
47
47
  if (!kind) { logger.warn?.(`[rag-indexer] unknown kind: ${path}`); return { indexed: 0, failed: 0 }; }
@@ -53,7 +53,7 @@ export async function indexFile(path, { db, embedder, logger = console } = {}) {
53
53
  for (const ch of chunks) {
54
54
  try {
55
55
  const embedding = await embedder.embed(ch.text);
56
- insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding });
56
+ insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project });
57
57
  indexed += 1;
58
58
  } catch (err) {
59
59
  failed += 1;
@@ -95,20 +95,25 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
95
95
  if (existsSync(planRoot)) {
96
96
  const plans = await listFiles(planRoot, (p) => /plan-.*\.json$/.test(p));
97
97
  for (const p of plans) {
98
- const r = await indexFile(p, { db, embedder, logger });
98
+ const r = await indexFile(p, { db, embedder, logger, project: slug });
99
99
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
100
100
  }
101
101
  }
102
102
  const onboarding = join(karajanHome, ONBOARDING_DIR, `${slug}.md`);
103
103
  if (existsSync(onboarding)) {
104
- const r = await indexFile(onboarding, { db, embedder, logger });
104
+ const r = await indexFile(onboarding, { db, embedder, logger, project: slug });
105
105
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
106
106
  }
107
107
  if (withSources) {
108
- const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next"]);
108
+ // KJC-BUG-0062: `.kj/worktrees/HU-*/` contains git worktrees Karajan spawns
109
+ // per HU during `kj run`; they hold complete copies of `src/` that
110
+ // duplicate every chunk and contaminate retrieval scores. `_diet` is the
111
+ // tests/_diet/ sandbox used by the test-diet audit harness — never user
112
+ // code. Skip both.
113
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
109
114
  const sources = await listFiles(projectDir, (p) => /\.(js|mjs|cjs|ts|tsx|jsx)$/.test(p) && !p.split("/").some((seg) => SKIP.has(seg)));
110
115
  for (const s of sources) {
111
- const r = await indexFile(s, { db, embedder, logger });
116
+ const r = await indexFile(s, { db, embedder, logger, project: slug });
112
117
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
113
118
  }
114
119
  }
@@ -10,6 +10,24 @@ import { searchSimilar } from "./vec-store.js";
10
10
  // between equidistant chunks but won't reorder by big distance gaps.
11
11
  const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
12
12
 
13
+ // KJC-TSK-0440 — asymmetric source/test boost. v2.26 smoke caught a
14
+ // systematic bias: queries in natural language ("how does X work") return
15
+ // tests/X.test.js ahead of src/X.js because tests carry more descriptive
16
+ // prose. When the query itself does NOT mention test-flavoured terms,
17
+ // give code chunks +0.05 — but only chunks whose source path is NOT a
18
+ // test file. Tests keep the baseline boost.
19
+ const SOURCE_BOOST_NON_TEST = 0.05;
20
+ const TEST_TERMS_RE = /\b(test|tests|spec|specs|expect|describe|it\(|jest|vitest|mocha)\b/i;
21
+ const TEST_PATH_RE = /[\\/](tests?|specs?|__tests__)[\\/]|\.test\.[jt]sx?$|\.spec\.[jt]sx?$/i;
22
+
23
+ function shouldBoostSources(queryText) {
24
+ return !TEST_TERMS_RE.test(queryText);
25
+ }
26
+
27
+ function isTestPath(source) {
28
+ return TEST_PATH_RE.test(source || "");
29
+ }
30
+
13
31
  /**
14
32
  * Run a natural-language query against the indexed RAG corpus. Returns
15
33
  * the top-K chunks ranked by adjusted distance (lower = closer).
@@ -25,7 +43,7 @@ const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
25
43
  * @param {'plans'|'code'|'onboarding'|'all'} [opts.scope='all']
26
44
  * @param {Object} [opts.kindBoost]
27
45
  */
28
- export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST } = {}) {
46
+ export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST, project = null } = {}) {
29
47
  if (!text || typeof text !== "string") throw new Error("query: text must be a non-empty string");
30
48
  const queryEmbedding = await embedder.embed(text);
31
49
  // Over-fetch (topK * 2) so the rerank step has room to reshuffle
@@ -33,15 +51,27 @@ export async function query(db, embedder, text, { topK = 5, scope = "all", kindB
33
51
  // avoid loading the entire store on tiny topK calls.
34
52
  const fetchK = Math.min(50, topK * 2);
35
53
  const scopeKind = scope === "plans" ? "plan" : scope === "code" ? "code" : scope === "onboarding" ? "onboarding" : null;
36
- const raw = searchSimilar(db, queryEmbedding, fetchK, { kind: scopeKind });
54
+ // KJC-TSK-0438 — `project` filters by chunks.project_slug. null means
55
+ // no filter (pre-v2.27 behaviour). Caller's CLI defaults this to the
56
+ // basename of cwd; pass "all" through commands/rag.js to disable.
57
+ const raw = searchSimilar(db, queryEmbedding, fetchK, { kind: scopeKind, project });
37
58
  if (raw.length === 0) return [];
38
59
  // Adjust each chunk's distance by its kind boost. The vec store returns
39
60
  // cosine distance (smaller = closer); subtracting the boost makes
40
61
  // higher-priority kinds appear "closer" without altering the source
41
62
  // truth in the DB.
42
- const reranked = raw.map((r) => ({
43
- ...r,
44
- score: r.distance - (kindBoost[r.kind] || 0),
45
- })).sort((a, b) => a.score - b.score).slice(0, topK);
63
+ // KJC-TSK-0440 — apply the source-vs-test asymmetric boost only when
64
+ // the query itself is not test-flavoured. Test-flavoured queries keep
65
+ // the symmetric baseline so e.g. "vitest mock setup" still surfaces
66
+ // test files.
67
+ const boostSources = shouldBoostSources(text);
68
+ const reranked = raw.map((r) => {
69
+ const kindB = kindBoost[r.kind] || 0;
70
+ const sourceB = (boostSources && r.kind === "code" && !isTestPath(r.source)) ? SOURCE_BOOST_NON_TEST : 0;
71
+ return { ...r, score: r.distance - kindB - sourceB };
72
+ }).sort((a, b) => a.score - b.score).slice(0, topK);
46
73
  return reranked;
47
74
  }
75
+
76
+ // Exported for tests.
77
+ export { shouldBoostSources, isTestPath };
@@ -45,6 +45,14 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
45
45
  CREATE INDEX IF NOT EXISTS chunks_by_source ON chunks(source);
46
46
  CREATE INDEX IF NOT EXISTS chunks_by_kind ON chunks(kind);
47
47
  `);
48
+ // KJC-TSK-0438 — Project isolation. project_slug = basename of projectDir
49
+ // normalized; chunks indexed before the migration carry NULL and remain
50
+ // queryable when `--project all` (or no slug detected at query time).
51
+ const cols = db.prepare("PRAGMA table_info(chunks)").all().map((c) => c.name);
52
+ if (!cols.includes("project_slug")) {
53
+ db.exec("ALTER TABLE chunks ADD COLUMN project_slug TEXT;");
54
+ db.exec("CREATE INDEX IF NOT EXISTS chunks_by_project ON chunks(project_slug);");
55
+ }
48
56
  db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[${dim}]);`);
49
57
  return db;
50
58
  }
@@ -54,13 +62,13 @@ export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
54
62
  * because the virtual table rowid is set explicitly to the freshly
55
63
  * minted chunks.id. Returns that id.
56
64
  */
57
- export function insertChunk(db, { source, kind, text, metadata = null, embedding }) {
65
+ export function insertChunk(db, { source, kind, text, metadata = null, embedding, project = null }) {
58
66
  if (!Array.isArray(embedding) && !ArrayBuffer.isView(embedding)) {
59
67
  throw new Error("insertChunk: embedding must be an array or typed array of floats");
60
68
  }
61
69
  const meta = metadata == null ? null : (typeof metadata === "string" ? metadata : JSON.stringify(metadata));
62
- const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata) VALUES (?, ?, ?, ?)");
63
- const info = ins.run(source, kind, text, meta);
70
+ const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata, project_slug) VALUES (?, ?, ?, ?, ?)");
71
+ const info = ins.run(source, kind, text, meta, project);
64
72
  // sqlite-vec's vec0 virtual table requires the rowid to arrive as a BigInt
65
73
  // even for values that comfortably fit in a Number. Better-sqlite3 returns
66
74
  // lastInsertRowid as BigInt only when safeIntegers mode is on; force it.
@@ -75,22 +83,36 @@ export function insertChunk(db, { source, kind, text, metadata = null, embedding
75
83
  * text, metadata, distance }]` for the topK best matches; lower
76
84
  * distance = closer. Returns `[]` when the store is empty.
77
85
  */
78
- export function searchSimilar(db, embedding, topK = 5, { kind = null } = {}) {
86
+ export function searchSimilar(db, embedding, topK = 5, { kind = null, project = null } = {}) {
79
87
  const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
80
88
  const kindClause = kind ? "AND c.kind = ?" : "";
89
+ const projectClause = project ? "AND c.project_slug = ?" : "";
81
90
  const sql = `
82
- SELECT c.id, c.source, c.kind, c.text, c.metadata, v.distance
91
+ SELECT c.id, c.source, c.kind, c.text, c.metadata, c.project_slug, v.distance
83
92
  FROM vec_chunks v
84
93
  JOIN chunks c ON c.id = v.rowid
85
- WHERE v.embedding MATCH ? AND k = ? ${kindClause}
94
+ WHERE v.embedding MATCH ? AND k = ? ${kindClause} ${projectClause}
86
95
  ORDER BY v.distance
87
96
  `;
88
97
  const params = [Buffer.from(buf.buffer), topK];
89
98
  if (kind) params.push(kind);
99
+ if (project) params.push(project);
90
100
  const rows = db.prepare(sql).all(...params);
91
101
  return rows.map((r) => ({ ...r, metadata: r.metadata ? safeParse(r.metadata) : null }));
92
102
  }
93
103
 
104
+ /**
105
+ * Normalize a projectDir into a stable slug used for project isolation.
106
+ * Basename → lowercased → non-alphanumeric stripped to dashes.
107
+ * KJC-TSK-0438.
108
+ */
109
+ export function projectSlug(projectDir) {
110
+ if (!projectDir) return null;
111
+ const base = String(projectDir).replace(/\/+$/, "").split("/").pop();
112
+ if (!base) return null;
113
+ return base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
114
+ }
115
+
94
116
  function safeParse(s) { try { return JSON.parse(s); } catch { return s; } }
95
117
 
96
118
  /**