karajan-code 2.33.0 → 3.0.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 (53) hide show
  1. package/README.md +136 -36
  2. package/bin/kj-rag-mcp.js +75 -0
  3. package/docs/README.es.md +4 -4
  4. package/package.json +22 -16
  5. package/packages/hu-board/package.json +5 -2
  6. package/scripts/git-hooks/post-merge +7 -0
  7. package/src/audit/basal-cost.js +17 -12
  8. package/src/audit/deterministic-summary.js +20 -0
  9. package/src/audit/injection-findings.js +105 -0
  10. package/src/checks/hardware.js +150 -0
  11. package/src/checks/node.js +9 -10
  12. package/src/cli/register-meta.js +57 -1
  13. package/src/cli/register-pipeline.js +1 -0
  14. package/src/commands/doctor.js +2 -0
  15. package/src/commands/init.js +41 -1
  16. package/src/commands/rag-mcp.js +18 -0
  17. package/src/commands/rag.js +82 -8
  18. package/src/commands/run.js +8 -0
  19. package/src/commands/telemetry.js +70 -0
  20. package/src/config/defaults.js +11 -1
  21. package/src/lang/chunk-go.js +94 -0
  22. package/src/lang/chunk-java.js +90 -0
  23. package/src/lang/chunk-python.js +88 -0
  24. package/src/lang/chunk-rust.js +102 -0
  25. package/src/lang/go.js +14 -0
  26. package/src/lang/java.js +16 -0
  27. package/src/lang/js.js +8 -0
  28. package/src/lang/python.js +14 -0
  29. package/src/lang/registry.js +79 -0
  30. package/src/lang/rust.js +14 -0
  31. package/src/lang/tree-sitter-loader.js +49 -0
  32. package/src/onboarder/collectors/framework-multilang.js +64 -0
  33. package/src/onboarder/collectors/index.js +18 -1
  34. package/src/orchestrator/stages/coder-stage.js +3 -0
  35. package/src/orchestrator/stages/reviewer-stage.js +11 -0
  36. package/src/rag/auto-update.js +50 -0
  37. package/src/rag/chunker.js +7 -3
  38. package/src/rag/eval.js +61 -0
  39. package/src/rag/indexer.js +81 -10
  40. package/src/rag/retriever.js +57 -5
  41. package/src/rag/vec-store.js +69 -3
  42. package/src/rag/watcher.js +6 -4
  43. package/src/roles/audit-role.js +9 -1
  44. package/src/sonar/scanner.js +51 -16
  45. package/src/utils/manifest-sniff.js +82 -0
  46. package/src/utils/quota-simulator.js +56 -0
  47. package/src/utils/telemetry.js +34 -12
  48. package/templates/roles/onboarder.md +10 -1
  49. package/vendor/tree-sitter-grammars/README.md +21 -0
  50. package/vendor/tree-sitter-grammars/go.wasm +0 -0
  51. package/vendor/tree-sitter-grammars/java.wasm +0 -0
  52. package/vendor/tree-sitter-grammars/python.wasm +0 -0
  53. package/vendor/tree-sitter-grammars/rust.wasm +0 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Prompt-injection findings collector for `kj audit` (KJC-TSK-0498).
3
+ * Scans `.karajan/specs/**`, `.karajan/domain.md` and
4
+ * `.karajan/onboarding/**` reusing `src/utils/injection-guard.js`.
5
+ * Best-effort: missing folders return `scanned: 0`.
6
+ */
7
+
8
+ import { readdir, readFile, stat } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { scanForInjection } from "../utils/injection-guard.js";
11
+
12
+ const MAX_FILE_BYTES = 256 * 1024;
13
+ const MAX_DEPTH = 6;
14
+ const SEVERITY_BY_TYPE = { directive: "HIGH", unicode: "MEDIUM", comment_block: "LOW" };
15
+
16
+ const SCAN_TARGETS = [
17
+ { rel: ".karajan/specs", kind: "dir" },
18
+ { rel: ".karajan/onboarding", kind: "dir" },
19
+ { rel: ".karajan/domain.md", kind: "file" },
20
+ ];
21
+
22
+ export async function collectInjectionFindings(rootDir, logger = null) {
23
+ if (!rootDir) return { available: false, reason: "rootDir not provided" };
24
+ const findings = [];
25
+ let scanned = 0;
26
+ try {
27
+ for (const target of SCAN_TARGETS) {
28
+ const abs = path.join(rootDir, target.rel);
29
+ const files = target.kind === "dir"
30
+ ? await listFiles(abs, 0)
31
+ : (await fileExists(abs)) ? [abs] : [];
32
+ for (const file of files) {
33
+ scanned += 1;
34
+ const content = await safeRead(file);
35
+ if (!content) continue;
36
+ const result = scanForInjection(content);
37
+ if (result.clean) continue;
38
+ for (const f of result.findings) {
39
+ findings.push({
40
+ file: path.relative(rootDir, file),
41
+ type: f.type,
42
+ pattern: f.pattern,
43
+ snippet: f.snippet,
44
+ line: f.line,
45
+ severity: SEVERITY_BY_TYPE[f.type] || "LOW",
46
+ });
47
+ }
48
+ }
49
+ }
50
+ } catch (err) {
51
+ if (logger?.warn) logger.warn(`injection-findings collector failed: ${err.message}`);
52
+ return { available: false, reason: `collector failed: ${err.message}` };
53
+ }
54
+ return { available: true, scanned, total: findings.length, findings };
55
+ }
56
+
57
+ export function groupInjectionBySeverity(findings) {
58
+ const groups = { HIGH: [], MEDIUM: [], LOW: [] };
59
+ for (const f of findings || []) {
60
+ const sev = (f.severity || "LOW").toUpperCase();
61
+ if (groups[sev]) groups[sev].push(f);
62
+ else groups.LOW.push(f);
63
+ }
64
+ return groups;
65
+ }
66
+
67
+ async function listFiles(dir, depth) {
68
+ if (depth > MAX_DEPTH) return [];
69
+ let entries;
70
+ try {
71
+ entries = await readdir(dir, { withFileTypes: true });
72
+ } catch {
73
+ return [];
74
+ }
75
+ const out = [];
76
+ for (const entry of entries) {
77
+ const abs = path.join(dir, entry.name);
78
+ if (entry.isDirectory()) {
79
+ const nested = await listFiles(abs, depth + 1);
80
+ out.push(...nested);
81
+ } else if (entry.isFile()) {
82
+ out.push(abs);
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ async function fileExists(file) {
89
+ try {
90
+ const s = await stat(file);
91
+ return s.isFile();
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ async function safeRead(file) {
98
+ try {
99
+ const s = await stat(file);
100
+ if (s.size > MAX_FILE_BYTES) return null;
101
+ return await readFile(file, "utf8");
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
@@ -0,0 +1,150 @@
1
+ // Advisory hardware checks: warn (not fail) so the pipeline keeps running.
2
+ // RAM < 4 GB silently OOM-kills the RAG embedder; disk < 5 GB corrupts
3
+ // Ollama model shards. GPU detection is best-effort and never throws.
4
+
5
+ import os from "node:os";
6
+ import { statfs } from "node:fs/promises";
7
+ import { spawn } from "node:child_process";
8
+ import { STRATEGY } from "./types.js";
9
+
10
+ const RAM_WARN_GB = 4;
11
+ const RAM_RECOMMENDED_GB = 8;
12
+ const DISK_WARN_GB = 5;
13
+ const DISK_RECOMMENDED_GB = 20;
14
+ const GPU_DETECT_TIMEOUT_MS = 1500;
15
+
16
+ const bytesToGb = (bytes) => Math.round((bytes / 1024 ** 3) * 10) / 10;
17
+ const info = (detail, extra) => ({ ok: true, severity: "info", detail, extra });
18
+ const warn = (detail, fix, extra) => ({ ok: false, severity: "warn", detail, fix, extra });
19
+
20
+ function createRamCheck() {
21
+ return {
22
+ name: "hw-ram",
23
+ label: "System RAM",
24
+ strategy: STRATEGY.MANUAL,
25
+ async detect() {
26
+ const totalGb = bytesToGb(os.totalmem());
27
+ const freeGb = bytesToGb(os.freemem());
28
+ const base = `${totalGb} GB total, ${freeGb} GB free`;
29
+ if (totalGb < RAM_WARN_GB) {
30
+ return warn(
31
+ `${base} (RAG embedder needs >=${RAM_WARN_GB} GB)`,
32
+ "Disable local RAG (kj init --no-rag) or switch rag.embedder.provider to openai/voyage/cohere.",
33
+ { totalGb, freeGb, threshold: RAM_WARN_GB }
34
+ );
35
+ }
36
+ if (totalGb < RAM_RECOMMENDED_GB) {
37
+ return info(`${base} (>=${RAM_RECOMMENDED_GB} GB recommended for parallel runs)`, { totalGb, freeGb });
38
+ }
39
+ return info(base, { totalGb, freeGb });
40
+ },
41
+ };
42
+ }
43
+
44
+ function createCpuCheck() {
45
+ return {
46
+ name: "hw-cpu",
47
+ label: "CPU",
48
+ strategy: STRATEGY.NONE,
49
+ async detect() {
50
+ const cpus = os.cpus();
51
+ const model = cpus[0]?.model?.trim() || "unknown";
52
+ const cores = cpus.length;
53
+ const arch = os.arch();
54
+ return info(`${cores} cores, ${arch} (${model})`, { cores, arch, model });
55
+ },
56
+ };
57
+ }
58
+
59
+ function createDiskCheck() {
60
+ return {
61
+ name: "hw-disk",
62
+ label: "Disk space (cwd)",
63
+ strategy: STRATEGY.MANUAL,
64
+ async detect() {
65
+ try {
66
+ const stat = await statfs(process.cwd());
67
+ const freeGb = bytesToGb(stat.bsize * stat.bavail);
68
+ if (freeGb < DISK_WARN_GB) {
69
+ return warn(
70
+ `${freeGb} GB free (>=${DISK_WARN_GB} GB recommended)`,
71
+ "Free disk space. Ollama models alone take 3-5 GB; the RAG store grows ~50 MB per 1k LOC indexed.",
72
+ { freeGb, threshold: DISK_WARN_GB }
73
+ );
74
+ }
75
+ const note = freeGb < DISK_RECOMMENDED_GB ? ` (>=${DISK_RECOMMENDED_GB} GB recommended)` : "";
76
+ return info(`${freeGb} GB free${note}`, { freeGb });
77
+ } catch (err) {
78
+ return info(`Cannot read free disk space: ${err.message}`);
79
+ }
80
+ },
81
+ };
82
+ }
83
+
84
+ function runGpuCommand(cmd, args) {
85
+ return new Promise((resolve) => {
86
+ let stdout = "";
87
+ let settled = false;
88
+ const finish = (value) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ resolve(value);
92
+ };
93
+ try {
94
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
95
+ const timer = setTimeout(() => {
96
+ try { child.kill("SIGKILL"); } catch { /* noop */ }
97
+ finish(null);
98
+ }, GPU_DETECT_TIMEOUT_MS);
99
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
100
+ child.on("error", () => { clearTimeout(timer); finish(null); });
101
+ child.on("close", (code) => {
102
+ clearTimeout(timer);
103
+ finish(code === 0 ? stdout : null);
104
+ });
105
+ } catch {
106
+ finish(null);
107
+ }
108
+ });
109
+ }
110
+
111
+ async function detectGpu() {
112
+ const platform = process.platform;
113
+ if (platform === "linux" || platform === "win32") {
114
+ const out = await runGpuCommand("nvidia-smi", ["--query-gpu=name", "--format=csv,noheader"]);
115
+ const model = out?.split("\n").map((l) => l.trim()).find(Boolean);
116
+ if (model) return { vendor: "NVIDIA", model };
117
+ return null;
118
+ }
119
+ if (platform === "darwin") {
120
+ const out = await runGpuCommand("system_profiler", ["SPDisplaysDataType"]);
121
+ if (!out) return null;
122
+ const match = out.match(/Chipset Model:\s*(.+)/);
123
+ if (!match) return null;
124
+ const model = match[1].trim();
125
+ const vendor = /Apple/i.test(model) ? "Apple" : /AMD|Radeon/i.test(model) ? "AMD" : /Intel/i.test(model) ? "Intel" : "GPU";
126
+ return { vendor, model };
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function createGpuCheck() {
132
+ return {
133
+ name: "hw-gpu",
134
+ label: "GPU",
135
+ strategy: STRATEGY.NONE,
136
+ async detect() {
137
+ const gpu = await detectGpu();
138
+ if (!gpu) {
139
+ return info("No discrete GPU detected (CPU-only)", { detected: false });
140
+ }
141
+ return info(`${gpu.vendor} ${gpu.model}`, { detected: true, vendor: gpu.vendor, model: gpu.model });
142
+ },
143
+ };
144
+ }
145
+
146
+ export function getHardwareChecks() {
147
+ return [createRamCheck(), createCpuCheck(), createDiskCheck(), createGpuCheck()];
148
+ }
149
+
150
+ export const __test = { RAM_WARN_GB, RAM_RECOMMENDED_GB, DISK_WARN_GB, DISK_RECOMMENDED_GB, GPU_DETECT_TIMEOUT_MS, bytesToGb, detectGpu };
@@ -1,18 +1,17 @@
1
1
  /**
2
- * Node.js runtime version check. Requires Node >= 18 — the LTS baseline that
3
- * ships every runtime feature Karajan needs: ESM support, `fetch` (built-in),
4
- * `structuredClone` (17+), `AbortSignal.timeout` (17.3+), and
5
- * `Array.prototype.findLast` / `findLastIndex` (18+). Nothing in the runtime
6
- * code path uses Node 20+ APIs (no `Promise.withResolvers`, no
7
- * `Object.groupBy`, no `Set.prototype.union/intersection`, no
8
- * `Array.prototype.toSorted`). Strategy: manual — we cannot auto-upgrade the
9
- * runtime. Users on older Node get a clear upgrade instruction with nvm + brew
10
- * hints instead of a silent runtime crash.
2
+ * Node.js runtime version check. Requires Node >= 22 — the Active LTS
3
+ * baseline since Karajan v3.0.0 (KJC-TSK-0500, 2026-06-03). Node 20 was
4
+ * dropped because (a) Node 20 reaches EOL on 2026-04-30 and (b) three
5
+ * dependencies forced the bump: lint-staged 17 needs ≥22.22.1, commander
6
+ * 15 needs ≥22.12.0, and better-sqlite3 v12.10+ dropped Node 20 prebuilds.
7
+ * Strategy: manual — we cannot auto-upgrade the runtime. Users on older
8
+ * Node get a clear upgrade instruction with nvm + brew hints instead of a
9
+ * silent runtime crash.
11
10
  */
12
11
 
13
12
  import { STRATEGY } from "./types.js";
14
13
 
15
- export const MIN_NODE_MAJOR = 18;
14
+ export const MIN_NODE_MAJOR = 22;
16
15
 
17
16
  /**
18
17
  * Accepts a version string in the form "v20.12.1" or "20.12.1" and returns
@@ -3,7 +3,8 @@ import { triageCommand } from "../commands/triage.js";
3
3
  import { researcherCommand } from "../commands/researcher.js";
4
4
  import { architectCommand } from "../commands/architect.js";
5
5
  import { onboardCommand } from "../commands/onboard.js";
6
- import { ragIndexCommand, ragQueryCommand } from "../commands/rag.js";
6
+ import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
7
+ import { ragMcpCommand } from "../commands/rag-mcp.js";
7
8
  import { watchStartCommand, watchStopCommand, watchStatusCommand } from "../commands/watch.js";
8
9
  import { auditCommand } from "../commands/audit.js";
9
10
  import { resumeCommand } from "../commands/resume.js";
@@ -12,6 +13,7 @@ import { webperfCommand } from "../commands/webperf.js";
12
13
  import { undoCommand } from "../commands/undo.js";
13
14
  import { syncCommand } from "../commands/sync.js";
14
15
  import { cleanCommand } from "../commands/clean.js";
16
+ import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
15
17
  import { withConfig } from "./_shared.js";
16
18
 
17
19
  /**
@@ -104,12 +106,21 @@ export function registerMeta(program, { pkgVersion }) {
104
106
  rag.command("index")
105
107
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
106
108
  .option("--with-sources", "Also index the projectDir's JS/TS files")
109
+ .option("--since <ref>", "Only re-index files changed since <ref> ('auto' uses the last indexed commit; falls back to full index if unset). KJC-TSK-0455.")
107
110
  .option("--json", "Output the totals as JSON")
108
111
  .action(async (flags) => {
109
112
  await withConfig(pkgVersion, "rag-index", flags, async ({ config, logger }) => {
110
113
  await ragIndexCommand({ config, logger, flags });
111
114
  });
112
115
  });
116
+ rag.command("install-hooks")
117
+ .description("Install the post-merge git hook so the RAG index auto-refreshes after pulls/merges (KJC-TSK-0455)")
118
+ .option("--json", "Output the result as JSON")
119
+ .action(async (flags) => {
120
+ await withConfig(pkgVersion, "rag-install-hooks", flags, async ({ config, logger }) => {
121
+ await ragInstallHooksCommand({ config, logger, flags });
122
+ });
123
+ });
113
124
  rag.command("query <text>")
114
125
  .description("Run a semantic query against the indexed RAG corpus")
115
126
  .option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
@@ -136,6 +147,30 @@ export function registerMeta(program, { pkgVersion }) {
136
147
  await ragQueryCommand({ text, config, logger, flags: { ...flags, mode: ragMode } });
137
148
  });
138
149
  });
150
+ // KJC-TSK-0483 — `kj rag eval` runs the golden-query harness and reports
151
+ // recall@5/recall@10/MRR. `--min-recall` turns it into a CI gate.
152
+ rag.command("eval")
153
+ .description("Score the retriever against a golden query set (recall@k + MRR)")
154
+ .option("--golden <path>", "Path to golden queries JSON (default: tests/rag/golden-queries.json)")
155
+ .option("--top-k <n>", "Number of hits to retrieve per query (default: 10)", "10")
156
+ .option("--min-recall <n>", "Fail (exit 1) if aggregated recall@5 falls below this threshold")
157
+ .option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project.")
158
+ .option("--json", "Emit the full report as JSON")
159
+ .action(async (flags) => {
160
+ await withConfig(pkgVersion, "rag-eval", flags, async ({ config, logger }) => {
161
+ await ragEvalCommand({ config, logger, flags });
162
+ });
163
+ });
164
+
165
+ // KJC-TSK-0492 PR3 — `kj rag mcp` exposes the kj-rag-mcp binary under the
166
+ // familiar `kj rag <subcommand>` surface so users discover it without
167
+ // grepping bin/. The action dynamic-imports the binary, which takes over
168
+ // stdio for MCP transport; the CLI process never returns.
169
+ rag.command("mcp")
170
+ .description("Start the standalone RAG MCP server (kj_rag_query + kj_rag_index only). Same as running `kj-rag-mcp` directly")
171
+ .action(async () => {
172
+ await ragMcpCommand();
173
+ });
139
174
 
140
175
  // KJC-TSK-0441 — `kj watch [start|stop|status]` for live RAG re-index.
141
176
  const watch = program.command("watch").description("Live RAG re-index daemon");
@@ -287,6 +322,27 @@ export function registerMeta(program, { pkgVersion }) {
287
322
  });
288
323
  });
289
324
 
325
+ // KJC-TSK-0496 — surface the exact telemetry payload without sending it,
326
+ // and report whether telemetry is currently enabled. Read-only inspection
327
+ // commands so users can audit the contract without grepping the source.
328
+ const telemetry = program.command("telemetry").description("Inspect what Karajan telemetry sends and whether it's enabled (never opens a socket)");
329
+ telemetry.command("preview")
330
+ .description("Print the exact JSON payload for every event type (install, cli_command, pipeline_complete) — no network call")
331
+ .option("--json", "Emit the full preview as JSON")
332
+ .action(async (flags) => {
333
+ await withConfig(pkgVersion, "telemetry:preview", flags, async ({ config, logger }) => {
334
+ await telemetryPreviewCommand({ config, logger, version: pkgVersion, json: Boolean(flags.json) });
335
+ });
336
+ });
337
+ telemetry.command("status")
338
+ .description("Report whether telemetry is enabled and where the endpoint lives")
339
+ .option("--json", "Emit the status as JSON")
340
+ .action(async (flags) => {
341
+ await withConfig(pkgVersion, "telemetry:status", flags, async ({ config, logger }) => {
342
+ await telemetryStatusCommand({ config, logger, json: Boolean(flags.json) });
343
+ });
344
+ });
345
+
290
346
  program
291
347
  .command("clean")
292
348
  .description("Garbage-collect stale plans, sessions and HU batches (dry-run by default)")
@@ -110,6 +110,7 @@ export function registerPipeline(program, { pkgVersion }) {
110
110
  .option("--skip-role <role...>", "Force one or more roles OFF regardless of triage (e.g. --skip-role tester security)")
111
111
  .option("--force-role <role...>", "Force one or more roles ON regardless of triage")
112
112
  .option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
113
+ .option("--no-rag-update", "Skip the pre-run RAG drift check (KJC-TSK-0455). Same as config.rag.autoUpdate.onRun=false.")
113
114
  .option("--dry-run", "Show what would be executed without running anything")
114
115
  .option("--json", "Output JSON only (no styled display)")
115
116
  .option("-q, --quiet", "Show only stage status lines, suppress raw agent output (default)")
@@ -29,6 +29,7 @@ import { getMcpHealthChecks } from "../checks/mcp-health.js";
29
29
  import { getSkillsChecks } from "../checks/skills.js";
30
30
  import { getDirSetupChecks } from "../checks/dir-setup.js";
31
31
  import { getProjectChecks } from "../checks/project-checks.js";
32
+ import { getHardwareChecks } from "../checks/hardware.js";
32
33
 
33
34
  /**
34
35
  * Build the list of Check objects applicable to the current config.
@@ -50,6 +51,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
50
51
  return [
51
52
  ...getSystemChecks(),
52
53
  ...getNodeChecks(),
54
+ ...getHardwareChecks(),
53
55
  ...getDirSetupChecks(),
54
56
  ...getConfigFileChecks(),
55
57
  ...getBinaryChecks(),
@@ -12,6 +12,7 @@ import { createWizard, isTTY } from "../utils/wizard.js";
12
12
  import { runCommand } from "../utils/process.js";
13
13
  import { getInstallCommand } from "../utils/os-detect.js";
14
14
  import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
15
+ import { buildTelemetryPayload, sendTelemetryEvent } from "../utils/telemetry.js";
15
16
  import { detectRtk } from "../utils/rtk-detect.js";
16
17
  import { installRtk } from "../utils/rtk-install.js";
17
18
  import { detectProjectStack } from "../utils/stack-detect.js";
@@ -256,6 +257,42 @@ async function askBoardSecurity(wizard, config, logger) {
256
257
  logger.info(` -> HU Board port: ${config.hu_board.port}`);
257
258
  }
258
259
 
260
+ /**
261
+ * Explicit, opt-in telemetry consent. We do NOT inherit a "Karajan-installed
262
+ * users want to help" assumption — silence and missing config keys both mean
263
+ * "no". The prompt is rendered in the OS locale (EN/ES today; falls back to
264
+ * EN for everything else) so users see it in their own language even before
265
+ * they've set `config.language`. See src/utils/telemetry.js and the
266
+ * "Privacy & Telemetry" section of README.md for what gets sent.
267
+ */
268
+ async function askTelemetry(wizard, config, logger) {
269
+ const locale = detectOsLocale();
270
+ const prompts = {
271
+ es: {
272
+ question: "¿Quieres ayudar a mejorar Karajan enviando telemetría anónima al proyecto?",
273
+ detail: " (versión, OS, comandos, duración del pipeline — sin código, sin tareas, sin datos personales)",
274
+ previewIntro: " Ejemplo del payload exacto que se enviaría al ejecutar `kj run` (puedes inspeccionar más con `kj telemetry preview`):",
275
+ enabled: " -> Telemetría: activada (¡gracias!)",
276
+ disabled: " -> Telemetría: desactivada",
277
+ },
278
+ en: {
279
+ question: "Help improve Karajan by sending anonymous telemetry to the project?",
280
+ detail: " (version, OS, commands, pipeline duration — no code, no tasks, no personal data)",
281
+ previewIntro: " Example of the exact payload that would be sent when you run `kj run` (inspect more with `kj telemetry preview`):",
282
+ enabled: " -> Telemetry: enabled (thank you!)",
283
+ disabled: " -> Telemetry: disabled",
284
+ },
285
+ };
286
+ const t = prompts[locale] || prompts.en;
287
+ logger.info(t.detail);
288
+ const example = buildTelemetryPayload("cli_command", { version: "x.y.z", command: "run" });
289
+ logger.info(t.previewIntro);
290
+ logger.info(` ${JSON.stringify(example)}`);
291
+ const enabled = await wizard.confirm(t.question, false);
292
+ config.telemetry = enabled;
293
+ logger.info(enabled ? t.enabled : t.disabled);
294
+ }
295
+
259
296
  async function runWizard(config, logger) {
260
297
  const agents = await detectAvailableAgents();
261
298
  const available = agents.filter((a) => a.available);
@@ -318,6 +355,10 @@ async function runWizard(config, logger) {
318
355
  await askBoardSecurity(wizard, config, logger);
319
356
  }
320
357
 
358
+ logger.info("");
359
+ logger.info("Privacy:");
360
+ await askTelemetry(wizard, config, logger);
361
+
321
362
  logger.info("");
322
363
  } finally {
323
364
  wizard.close();
@@ -735,7 +776,6 @@ export async function initCommand({ logger, flags = {} }) {
735
776
  await writeInitConfig(configPath, config);
736
777
 
737
778
  // Telemetry: anonymous install event (non-blocking)
738
- const { sendTelemetryEvent } = await import("../utils/telemetry.js");
739
779
  const { readFileSync } = await import("node:fs");
740
780
  const { fileURLToPath } = await import("node:url");
741
781
  try {
@@ -0,0 +1,18 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ /**
6
+ * `kj rag mcp` — spawn the standalone RAG MCP server inline.
7
+ *
8
+ * KJC-TSK-0492 PR3. Adds CLI discoverability for the `kj-rag-mcp` binary
9
+ * so external IDE users can find it via the same `kj rag <subcommand>`
10
+ * surface they already know. The implementation dynamic-imports the
11
+ * binary entry so behavior stays identical to running `kj-rag-mcp`
12
+ * directly — same handlers, same transport, same orphan guard.
13
+ */
14
+ export async function ragMcpCommand() {
15
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
16
+ const binPath = path.resolve(moduleDir, "../../bin/kj-rag-mcp.js");
17
+ await import(pathToFileURL(binPath).href);
18
+ }
@@ -2,10 +2,12 @@
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, projectSlug } from "../rag/vec-store.js";
5
+ import { openVecStore, countChunks, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "../rag/vec-store.js";
6
6
  import { makeEmbedder } from "../rag/embedders/factory.js";
7
- import { indexProject } from "../rag/indexer.js";
7
+ import { indexProject, indexProjectDelta } from "../rag/indexer.js";
8
8
  import { query } from "../rag/retriever.js";
9
+ import { installPostMergeHook } from "../rag/auto-update.js";
10
+ import { loadGoldenQueries, runEval } from "../rag/eval.js";
9
11
  import { getKarajanHome } from "../utils/paths.js";
10
12
 
11
13
  function openDb(config) {
@@ -16,15 +18,39 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
16
18
  const projectDir = config?.projectDir || process.cwd();
17
19
  const db = openDb(config);
18
20
  try {
19
- const totals = await indexProject(projectDir, {
20
- db, embedder: makeEmbedder(config),
21
- karajanHome: getKarajanHome(), logger,
22
- withSources: Boolean(flags.withSources),
23
- });
21
+ const slug = projectSlug(projectDir);
22
+ const embedder = makeEmbedder(config);
23
+ const sinceFlag = flags.since;
24
+ // KJC-TSK-0455 — `--since auto` resolves to the last commit we indexed;
25
+ // an explicit ref is honoured as-is. Without a baseline (first-time
26
+ // index) we fall back to a full reindex with a friendly warning so the
27
+ // hook in PR2 stays a no-op-friendly entrypoint.
28
+ let since = null;
29
+ if (sinceFlag) {
30
+ since = sinceFlag === "auto" ? getLastIndexedCommit(db, slug) : sinceFlag;
31
+ if (!since) logger.warn?.("[rag] --since auto: no previous index recorded; running full index");
32
+ }
33
+ let totals;
34
+ if (since) {
35
+ try {
36
+ totals = await indexProjectDelta(projectDir, { db, embedder, since, logger });
37
+ } catch (err) {
38
+ logger.warn?.(`[rag] delta index failed (${err.message}); falling back to full index`);
39
+ totals = null;
40
+ }
41
+ }
42
+ if (!totals) {
43
+ totals = await indexProject(projectDir, {
44
+ db, embedder,
45
+ karajanHome: getKarajanHome(), logger,
46
+ withSources: Boolean(flags.withSources),
47
+ });
48
+ }
49
+ if (totals.head) setLastIndexedCommit(db, slug, totals.head);
24
50
  if (flags.json) {
25
51
  process.stdout.write(`${JSON.stringify(totals)}\n`);
26
52
  } else {
27
- logger.info(`[rag] indexed ${totals.indexed} chunk(s) across ${totals.files} file(s) (${totals.failed} failed)`);
53
+ logger.info(`[rag] indexed ${totals.indexed} chunk(s) across ${totals.files} file(s) (${totals.failed} failed${totals.deleted ? `, ${totals.deleted} chunks deleted` : ""})`);
28
54
  }
29
55
  return totals;
30
56
  } finally {
@@ -32,6 +58,18 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
32
58
  }
33
59
  }
34
60
 
61
+ // KJC-TSK-0455 — `kj rag install-hooks` wires the post-merge hook so the
62
+ // local index keeps up after `git pull` / merges. Opt-in to keep us from
63
+ // silently writing under `.git/hooks/`; pre-run drift check covers users
64
+ // who never run it.
65
+ export async function ragInstallHooksCommand({ config, logger, flags = {} }) {
66
+ const projectDir = config?.projectDir || process.cwd();
67
+ const res = installPostMergeHook({ projectDir, logger });
68
+ if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
69
+ else if (res.installed) logger.info(`[rag] installed post-merge hook at ${res.target}`);
70
+ return res;
71
+ }
72
+
35
73
  export async function ragQueryCommand({ text, config, logger, flags = {} }) {
36
74
  if (!text) throw new Error("kj rag query: text argument required");
37
75
  const db = openDb(config);
@@ -75,3 +113,39 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
75
113
  db.close();
76
114
  }
77
115
  }
116
+
117
+ // KJC-TSK-0483 — `kj rag eval`. Runs the retriever against a golden query
118
+ // set and reports recall@5, recall@10 and MRR per query and aggregated.
119
+ // Exit code != 0 when `--min-recall` is set and the aggregated recall@5
120
+ // falls below the threshold — lets CI fail noisily on retrieval regressions.
121
+ const DEFAULT_GOLDEN = "tests/rag/golden-queries.json";
122
+ export async function ragEvalCommand({ config, logger, flags = {} }) {
123
+ const db = openDb(config);
124
+ try {
125
+ const goldenPath = flags.golden || DEFAULT_GOLDEN;
126
+ const queries = loadGoldenQueries(goldenPath);
127
+ const topK = Math.max(1, Number(flags.topK) || 10);
128
+ const detected = projectSlug(config?.projectDir || process.cwd());
129
+ const project = flags.project === "all" ? null : (flags.project || detected || null);
130
+ const embedder = makeEmbedder(config);
131
+ const runQuery = async (text, k) => query(db, embedder, text, { topK: k, scope: "all", project });
132
+ const report = await runEval(queries, runQuery, { topK, ks: [5, 10] });
133
+ if (flags.json) {
134
+ process.stdout.write(`${JSON.stringify(report)}\n`);
135
+ } else {
136
+ logger.info(`[rag-eval] ${report.aggregate.n} queries · recall@5=${report.aggregate.recall["@5"].toFixed(3)} · recall@10=${report.aggregate.recall["@10"].toFixed(3)} · MRR=${report.aggregate.mrr.toFixed(3)}`);
137
+ for (const r of report.results) {
138
+ const rank = r.firstRank == null ? "—" : `#${r.firstRank}`;
139
+ logger.info(` [${rank}] ${r.query}`);
140
+ }
141
+ }
142
+ const minRecall = Number(flags.minRecall);
143
+ if (Number.isFinite(minRecall) && report.aggregate.recall["@5"] < minRecall) {
144
+ logger.error?.(`[rag-eval] recall@5=${report.aggregate.recall["@5"].toFixed(3)} below --min-recall=${minRecall}`);
145
+ process.exitCode = 1;
146
+ }
147
+ return report;
148
+ } finally {
149
+ db.close();
150
+ }
151
+ }
@@ -12,6 +12,7 @@ import { resolveRole } from "../config.js";
12
12
  import { parseCardId } from "../planning-game/adapter.js";
13
13
  import { confirmCwd } from "../utils/cwd-confirm.js";
14
14
  import { runSpecReview } from "../spec-review/run-spec-review.js";
15
+ import { maybeAutoUpdate } from "../rag/auto-update.js";
15
16
 
16
17
  function createCliAskQuestion(opts = {}) {
17
18
  const { sessionId = null } = opts;
@@ -84,6 +85,13 @@ export async function runCommandHandler({ task, config, logger, flags }) {
84
85
  }
85
86
  }
86
87
 
88
+ // KJC-TSK-0455 — Pre-run drift check. Default ON; opt out with
89
+ // --no-rag-update (flag) or config.rag.autoUpdate.onRun=false. If the
90
+ // local index is behind HEAD we re-embed the delta before the agents
91
+ // start reading from the RAG store; on any failure we log and move on
92
+ // so a broken vector store never blocks a run.
93
+ await maybeAutoUpdate({ projectDir, config, logger, flags });
94
+
87
95
  // Best-effort session cleanup before starting
88
96
  try {
89
97
  const { cleanupExpiredSessions } = await import("../session-cleanup.js");