karajan-code 2.32.0 → 2.34.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 (44) hide show
  1. package/README.md +25 -0
  2. package/package.json +4 -2
  3. package/scripts/esbuild-sea.config.mjs +10 -1
  4. package/scripts/git-hooks/post-merge +7 -0
  5. package/src/audit/audit-history-display.js +61 -0
  6. package/src/audit/audit-history.js +102 -0
  7. package/src/audit/basal-cost.js +17 -12
  8. package/src/audit/harness-scorecard.js +51 -0
  9. package/src/audit/harness-section.js +77 -0
  10. package/src/checks/harness-scorecard.js +41 -0
  11. package/src/cli/register-meta.js +28 -1
  12. package/src/cli/register-pipeline.js +1 -0
  13. package/src/commands/audit.js +73 -16
  14. package/src/commands/doctor.js +2 -0
  15. package/src/commands/rag.js +82 -8
  16. package/src/commands/run.js +8 -0
  17. package/src/config/defaults.js +7 -0
  18. package/src/lang/chunk-go.js +94 -0
  19. package/src/lang/chunk-java.js +90 -0
  20. package/src/lang/chunk-python.js +88 -0
  21. package/src/lang/chunk-rust.js +102 -0
  22. package/src/lang/go.js +14 -0
  23. package/src/lang/java.js +16 -0
  24. package/src/lang/js.js +8 -0
  25. package/src/lang/python.js +14 -0
  26. package/src/lang/registry.js +79 -0
  27. package/src/lang/rust.js +14 -0
  28. package/src/lang/tree-sitter-loader.js +49 -0
  29. package/src/onboarder/collectors/framework-multilang.js +64 -0
  30. package/src/onboarder/collectors/index.js +18 -1
  31. package/src/rag/auto-update.js +50 -0
  32. package/src/rag/chunker.js +7 -3
  33. package/src/rag/eval.js +61 -0
  34. package/src/rag/indexer.js +81 -10
  35. package/src/rag/retriever.js +57 -5
  36. package/src/rag/vec-store.js +69 -3
  37. package/src/rag/watcher.js +6 -4
  38. package/src/utils/manifest-sniff.js +82 -0
  39. package/templates/roles/onboarder.md +10 -1
  40. package/vendor/tree-sitter-grammars/README.md +21 -0
  41. package/vendor/tree-sitter-grammars/go.wasm +0 -0
  42. package/vendor/tree-sitter-grammars/java.wasm +0 -0
  43. package/vendor/tree-sitter-grammars/python.wasm +0 -0
  44. package/vendor/tree-sitter-grammars/rust.wasm +0 -0
package/README.md CHANGED
@@ -345,6 +345,31 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.34.0 released** — Minor. Two epics in one window. **KJC-PCS-0052 "Multi-language RAG"** — Python, Rust, Go and Java join JS/TS as first-class citizens of the local RAG index (AST chunkers via `web-tree-sitter` + multi-lang watcher + audit/onboarder multi-stack manifest sniffer), so a polyglot repo gets the same semantic retrieval surface as a single-language one. **KJC-PCS-0053 "RAG Quality & Observability"** — `kj rag eval` ships a frozen golden-query harness with `recall@k + MRR` (CI-gated via `--min-recall`), content-hash dedup skips redundant embeddings, optional MMR diversification returns *different* chunks instead of five near-duplicates, and `docs/RAG.md` gains a six-questions deep-dive documenting the system end-to-end. `kj rag index --since <ref>` + a post-merge git hook keep the index in sync with `git pull` / merge without manual intervention.
349
+ >
350
+ > - **`vec_store_meta` + `kj rag index --since`** (PR #882, KJC-TSK-0455 PR1): per-project last-indexed-commit stamp; `--since auto` resolves it; explicit refs honoured; missing baseline falls back to a full index with a friendly warning. Idempotent.
351
+ > - **Post-merge hook + pre-run drift check** (PR #883, KJC-TSK-0455 PR2): new `kj rag install-hooks` writes `.git/hooks/post-merge` so `git pull` auto-triggers a delta re-index. Pre-run drift check emits a one-line hint when `HEAD` is ahead of the last indexed SHA by more than N files.
352
+ > - **Language registry + Python canary** (PR #884, KJC-TSK-0474): new `src/lang/registry.js` with `adapterForPath(file)`; the indexer + watcher both route through it. Python lands as the first non-JS adapter to exercise the registry shape from day one.
353
+ > - **Python AST chunker** (PR #886, KJC-TSK-0478) and **Rust AST chunker** (PR #888, KJC-TSK-0479): web-tree-sitter walkers + regex fallbacks, grammars vendored under `vendor/tree-sitter-grammars/*.wasm` so SEA binaries stay self-contained.
354
+ > - **Indexer prepares Python / Rust grammars** (PR #889, KJC-TSK-0480): `prepareAdapters()` awaits the grammar loads before walking so the first file doesn't pay the cold-load tax mid-batch.
355
+ > - **Go AST chunker** (PR #890, KJC-TSK-0481): tree-sitter walker + regex fallback. Method receivers rendered explicitly so retrieval surfaces method-vs-function disambiguation without re-parsing.
356
+ > - **Multi-stack manifest sniffer for `basal-cost`** (PR #891, KJC-TSK-0476): `kj audit` now reads Python (`pyproject.toml`, `requirements*.txt`), Rust (`Cargo.toml`), Go (`go.mod`) and Java (`pom.xml`, `build.gradle*`) manifests alongside `package.json`. Cost estimates no longer assume JS/TS.
357
+ > - **Multi-stack frameworks in audit bundle** (PR #892, KJC-TSK-0477): the audit bundle JSON gains a `stacks` field listing detected language stacks + framework hints (Django, Flask, Actix, Tokio, Gin, Echo, Spring, Quarkus...). Onboarder + audit role render polyglot reality.
358
+ > - **Java AST chunker** (PR #893, KJC-TSK-0486): two-level walker (class / interface / enum / record → method / constructor) + regex fallback. Inner classes flattened with `Outer$Inner` symbols.
359
+ > - **`docs/RAG.md` six-questions deep-dive** (PR #894, KJC-TSK-0485): per-language chunker table, six-provider embedder matrix, hybrid + rerank + metadata filter, update strategy table, validation tiers. Excluded from the shrink-budget gate.
360
+ > - **Watcher derived from language registry** (PR #895, KJC-TSK-0482): `kj watch` matcher no longer hard-codes JS/TS. Python / Rust / Go / Java sources trigger live re-index.
361
+ > - **Content-hash skip-on-match** (PR #896, KJC-TSK-0484 PR-A): each chunk gains a `sha256` column; identical bodies skip the Ollama call. Re-indexing identical files now costs zero embeddings.
362
+ > - **MMR diversification in retriever** (PR #898, KJC-TSK-0484 PR-B): optional `rag.search.mmr` over the top-N candidates; lambda (`rag.search.mmrLambda`, default 0.5) trades pure cosine relevance for diversity. Off by default.
363
+ > - **Retrieval-quality harness** (PR #899, KJC-TSK-0483 PR-A): pure `runEval(queries, runQuery, { topK, ks })` scoring recall@k (binary) + MRR. `tests/rag/golden-queries.json` ships 20 entries covering the public surface of `src/rag/`.
364
+ > - **`kj rag eval` CLI + baseline docs** (PR #900, KJC-TSK-0483 PR-B): subcommand wires the harness to the indexed corpus, emits aggregate + per-query report (human or `--json`), and sets `process.exitCode = 1` when `--min-recall <n>` is set and aggregate falls below — drop-in CI gate after `kj rag index`.
365
+
366
+ > **v2.33.0 released** — Minor. **AI Harness Scorecard golden metric (KJC-PCS-0051, Plan B)** — every `kj audit` now boots a Docker one-shot of `addyosmani/ai-harness-scorecard`, gets a deterministic 0–100 score + A–F grade, persists it to a per-project `audit-history.db`, and on the next run renders the delta vs the previous baseline plus an optional Unicode-bar trend sparkline. One golden number for "how AI-friendly is this repo today vs last week," zero LLM tokens spent.
367
+ >
368
+ > - **Harness Docker one-shot** (PR #877, KJC-TSK-0470): `src/audit/harness-runner.js` runs `docker run --rm -v <repo>:/repo addyosmani/ai-harness-scorecard analyze`, parses the verdict, auto-skips on missing Docker. `--no-harness` opts out.
369
+ > - **`kj audit` integrates the score** (PR #878, KJC-TSK-0471): new harness-section renderer; JSON output gains `harnessScore`; runs during the deterministic phase so `--deterministic-only` users get it too.
370
+ > - **Per-project audit history** (PR #879, KJC-TSK-0472): `.karajan/audit-history.db` (better-sqlite3 + WAL + `PRAGMA user_version=1`) persists every run. SEA bundle stubbed (degrades gracefully); npm install unlocks history.
371
+ > - **Diff + trend sparkline** (PR #880, KJC-TSK-0473): `src/audit/audit-history-display.js` (pure module, safe in SEA) computes overall delta + per-category deltas + biggest improvement/regression + stale-baseline flag (>30 days). New `--trend` flag prints a `▁▂▃▄▅▆▇█` sparkline over the last N runs.
372
+ >
348
373
  > **v2.32.0 released** — Minor. **AI Harness Scorecard hardening (KJC-PCS-0051)** — Plan A closes five FAILs from the external scorecard audit in one sprint: Prettier `--check`, Coverage v8 reports, Conventional Commits enforcement, nightly drift detection, and an unsafe-code lint policy. Plus two bug fixes shipped alongside.
349
374
  >
350
375
  > - **Prettier `--check` CI job** (PR #868, KJC-TSK-0464): new `format` job blocks PRs whose formatting drifts from `.prettierrc.json`. Scope narrow at first (`.github/workflows/`, root config); future PRs fold in more dirs under the shrink-budget cap.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.32.0",
3
+ "version": "2.34.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",
@@ -37,6 +37,7 @@
37
37
  "bin/",
38
38
  "templates/",
39
39
  "scripts/",
40
+ "vendor/tree-sitter-grammars/",
40
41
  "packages/hu-board/src/",
41
42
  "packages/hu-board/public/",
42
43
  "packages/hu-board/package.json",
@@ -85,7 +86,8 @@
85
86
  "knip": "^6.14.1",
86
87
  "madge": "^8.0.0",
87
88
  "sqlite-vec": "^0.1.9",
88
- "valibot": "^1.3.1"
89
+ "valibot": "^1.3.1",
90
+ "web-tree-sitter": "^0.26.9"
89
91
  },
90
92
  "devDependencies": {
91
93
  "@eslint/js": "^10.0.1",
@@ -191,6 +191,15 @@ const ragStubPlugin = {
191
191
  },
192
192
  };
193
193
 
194
+ // KJC-TSK-0472 — audit-history sqlite stub (same reasoning as rag/hu-board stubs).
195
+ const auditHistoryStubPlugin = {
196
+ name: "audit-history-stub",
197
+ setup(b) {
198
+ b.onResolve({ filter: /[\\/]audit[\\/]audit-history\.js$/ }, (a) => ({ path: a.path, namespace: "ahs" }));
199
+ b.onLoad({ filter: /.*/, namespace: "ahs" }, () => ({ contents: "module.exports = { __esModule: false, persistAuditRun: () => ({ ok: false, error: 'history disabled in SEA' }), getAuditHistoryDbPath: () => null, openAuditHistoryDb: () => null, recordAuditRun: () => null, listRecentRuns: () => [], countRuns: () => 0, pruneOldRuns: () => 0, getLatestPreviousRun: () => null, getRecentScores: () => [] };", loader: "js" }));
200
+ },
201
+ };
202
+
194
203
  /** @type {import('esbuild').BuildOptions} */
195
204
  export const seaBuildOptions = {
196
205
  entryPoints: ["src/cli.js"],
@@ -221,6 +230,6 @@ export const seaBuildOptions = {
221
230
  // throws → collector returns available:false. npm installs get knip
222
231
  // resolved normally from node_modules.
223
232
  external: ["better-sqlite3", "madge", "knip", "oxc-parser", "oxc-resolver"],
224
- plugins: [seaTransformPlugin, huBoardStubPlugin, ragStubPlugin],
233
+ plugins: [seaTransformPlugin, huBoardStubPlugin, ragStubPlugin, auditHistoryStubPlugin],
225
234
  logLevel: "info",
226
235
  };
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # KJC-TSK-0455 — Auto-refresh the local RAG index after a merge so retrieval
3
+ # never serves stale code. Installed by `kj rag install-hooks`. The marker
4
+ # in this comment is how the installer tells kj-managed hooks from user hooks.
5
+ set -euo pipefail
6
+ if ! command -v kj >/dev/null 2>&1; then exit 0; fi
7
+ kj rag index --since auto >/dev/null 2>&1 || true
@@ -0,0 +1,61 @@
1
+ // KJC-TSK-0473 — Pure display helpers for audit history (diff + sparkline).
2
+ // No DB / native-module deps; safe to bundle into the SEA binary.
3
+
4
+ const SPARK_BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
5
+
6
+ function scoreOf(c) {
7
+ if (!c || typeof c !== "object") return null;
8
+ return typeof c.score === "number" ? c.score : typeof c.percent === "number" ? c.percent : null;
9
+ }
10
+
11
+ export function computeHistoryDiff(currentHarness, previousRow) {
12
+ if (!currentHarness || typeof currentHarness.score !== "number") return null;
13
+ if (!previousRow || typeof previousRow.score !== "number") return { previousScore: null, firstRun: true };
14
+ let prevCats = {};
15
+ try { prevCats = previousRow.categories_json ? JSON.parse(previousRow.categories_json) || {} : {}; } catch { /* keep empty */ }
16
+ const currCats = currentHarness.categories || {};
17
+ const categoryDeltas = [];
18
+ for (const name of Object.keys(currCats)) {
19
+ const cur = scoreOf(currCats[name]);
20
+ const prev = scoreOf(prevCats[name]);
21
+ if (typeof cur === "number" && typeof prev === "number") {
22
+ categoryDeltas.push({ name, current: cur, previous: prev, delta: cur - prev });
23
+ }
24
+ }
25
+ const sorted = [...categoryDeltas].sort((a, b) => b.delta - a.delta);
26
+ const t = previousRow.timestamp ? Date.parse(previousRow.timestamp) : NaN;
27
+ const ageDays = Number.isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000);
28
+ return {
29
+ previousScore: previousRow.score,
30
+ previousGrade: previousRow.grade || null,
31
+ previousTimestamp: previousRow.timestamp,
32
+ delta: currentHarness.score - previousRow.score,
33
+ categoryDeltas,
34
+ biggestImprovement: sorted.find((c) => c.delta > 0) || null,
35
+ biggestRegression: [...sorted].reverse().find((c) => c.delta < 0) || null,
36
+ baselineAgeDays: ageDays,
37
+ baselineStale: ageDays != null && ageDays > 30,
38
+ };
39
+ }
40
+
41
+ export function formatHistoryDiff(diff) {
42
+ if (!diff) return "";
43
+ if (diff.firstRun) return "_First run for this project — no baseline yet._";
44
+ const arrow = diff.delta > 0 ? "↑" : diff.delta < 0 ? "↓" : "=";
45
+ const sign = diff.delta > 0 ? "+" : "";
46
+ const prevDate = (diff.previousTimestamp || "").slice(0, 10);
47
+ const lines = [`**Δ ${sign}${diff.delta} ${arrow} vs run anterior** (${prevDate}, ${diff.previousScore}/100 ${diff.previousGrade || ""})`];
48
+ if (diff.baselineStale) lines.push(`> ⚠ baseline antigua (${diff.baselineAgeDays} días) — considera ejecutar \`kj audit\` con más frecuencia.`);
49
+ if (diff.biggestImprovement) lines.push(`- Mayor mejora: **${diff.biggestImprovement.name}** (+${diff.biggestImprovement.delta})`);
50
+ if (diff.biggestRegression) lines.push(`- Mayor regresión: **${diff.biggestRegression.name}** (${diff.biggestRegression.delta})`);
51
+ return lines.join("\n");
52
+ }
53
+
54
+ export function formatTrendSparkline(scores) {
55
+ const xs = (scores || []).map((r) => (typeof r === "number" ? r : r?.score)).filter((n) => typeof n === "number");
56
+ if (xs.length === 0) return "";
57
+ if (xs.length === 1) return `Score trend: ${xs[0]} (single run)`;
58
+ const min = Math.min(...xs), max = Math.max(...xs), range = max - min || 1;
59
+ const bars = xs.map((n) => SPARK_BARS[Math.min(SPARK_BARS.length - 1, Math.floor(((n - min) / range) * (SPARK_BARS.length - 1)))]).join("");
60
+ return `Score trend (last ${xs.length} runs): ${xs[0]} ${bars} ${xs[xs.length - 1]}`;
61
+ }
@@ -0,0 +1,102 @@
1
+ // KJC-TSK-0472 — Per-project audit history (sqlite at .karajan/audit-history.db).
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import Database from "better-sqlite3";
5
+
6
+ const SCHEMA_VERSION = 1;
7
+
8
+ export function getAuditHistoryDbPath(projectDir) {
9
+ return path.join(projectDir, ".karajan", "audit-history.db");
10
+ }
11
+
12
+ export function openAuditHistoryDb(projectDir) {
13
+ const dbPath = getAuditHistoryDbPath(projectDir);
14
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
15
+ const db = new Database(dbPath);
16
+ db.pragma("journal_mode = WAL");
17
+ migrate(db);
18
+ return db;
19
+ }
20
+
21
+ function migrate(db) {
22
+ const current = db.pragma("user_version", { simple: true });
23
+ if (current >= SCHEMA_VERSION) return;
24
+ db.exec(`
25
+ CREATE TABLE IF NOT EXISTS audits (
26
+ run_id TEXT PRIMARY KEY,
27
+ timestamp TEXT NOT NULL,
28
+ git_sha TEXT,
29
+ score INTEGER,
30
+ grade TEXT,
31
+ categories_json TEXT,
32
+ raw_report_path TEXT
33
+ );
34
+ CREATE TABLE IF NOT EXISTS findings (
35
+ run_id TEXT NOT NULL,
36
+ category TEXT,
37
+ check_id TEXT,
38
+ status TEXT,
39
+ weight REAL,
40
+ FOREIGN KEY(run_id) REFERENCES audits(run_id) ON DELETE CASCADE
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_audits_timestamp ON audits(timestamp DESC);
43
+ CREATE INDEX IF NOT EXISTS idx_findings_run_id ON findings(run_id);
44
+ `);
45
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
46
+ }
47
+
48
+ // `harness` may be null (--no-harness / docker offline): row still inserted with NULL score.
49
+ export function recordAuditRun(db, { runId, timestamp, gitSha, harness, rawReportPath }) {
50
+ const categories = harness?.categories ? JSON.stringify(harness.categories) : null;
51
+ const checks = Array.isArray(harness?.checks) ? harness.checks : [];
52
+ const insertRun = db.prepare(`INSERT INTO audits (run_id, timestamp, git_sha, score, grade, categories_json, raw_report_path) VALUES (?, ?, ?, ?, ?, ?, ?)`);
53
+ const insertFinding = db.prepare(`INSERT INTO findings (run_id, category, check_id, status, weight) VALUES (?, ?, ?, ?, ?)`);
54
+ db.transaction(() => {
55
+ insertRun.run(runId, timestamp, gitSha || null, harness?.score ?? null, harness?.grade ?? null, categories, rawReportPath || null);
56
+ for (const c of checks) {
57
+ insertFinding.run(runId, c.category || null, c.name || c.id || null, c.passed ? "pass" : "fail", typeof c.weight === "number" ? c.weight : null);
58
+ }
59
+ })();
60
+ return { runId, inserted: 1 + checks.length };
61
+ }
62
+
63
+ export function listRecentRuns(db, limit = 10) {
64
+ return db.prepare(`SELECT run_id, timestamp, git_sha, score, grade FROM audits ORDER BY timestamp DESC LIMIT ?`).all(limit);
65
+ }
66
+
67
+ // KJC-TSK-0473 — Fetch previous run (excluding current) for diff rendering.
68
+ export function getLatestPreviousRun(db, currentTimestamp) {
69
+ return db.prepare(`SELECT run_id, timestamp, git_sha, score, grade, categories_json FROM audits WHERE timestamp < ? AND score IS NOT NULL ORDER BY timestamp DESC LIMIT 1`).get(currentTimestamp || new Date().toISOString()) || null;
70
+ }
71
+
72
+ // KJC-TSK-0473 — Score timeline (ASC) for sparkline rendering.
73
+ export function getRecentScores(db, limit = 10) {
74
+ const rows = db.prepare(`SELECT timestamp, score FROM audits WHERE score IS NOT NULL ORDER BY timestamp DESC LIMIT ?`).all(limit);
75
+ return rows.reverse();
76
+ }
77
+
78
+ export function countRuns(db) {
79
+ return db.prepare(`SELECT COUNT(*) AS n FROM audits`).get().n;
80
+ }
81
+
82
+ export function pruneOldRuns(db, keep = 50) {
83
+ const ids = db.prepare(`SELECT run_id FROM audits ORDER BY timestamp DESC LIMIT -1 OFFSET ?`).all(keep).map((r) => r.run_id);
84
+ if (ids.length === 0) return 0;
85
+ const ph = ids.map(() => "?").join(",");
86
+ db.transaction(() => { db.prepare(`DELETE FROM findings WHERE run_id IN (${ph})`).run(...ids); db.prepare(`DELETE FROM audits WHERE run_id IN (${ph})`).run(...ids); })();
87
+ return ids.length;
88
+ }
89
+
90
+ // High-level helper used by `kj audit`. Errors returned via `ok` so a broken db never breaks the audit.
91
+ export function persistAuditRun(projectDir, payload) {
92
+ try {
93
+ const db = openAuditHistoryDb(projectDir);
94
+ recordAuditRun(db, payload);
95
+ const n = countRuns(db);
96
+ db.close();
97
+ return { ok: true, total: n, pruneHint: n > 100 };
98
+ } catch (e) {
99
+ return { ok: false, error: e?.message || String(e) };
100
+ }
101
+ }
102
+
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { getKarajanHome } from "../utils/paths.js";
6
+ import { sniffManifests, summariseStack } from "../utils/manifest-sniff.js";
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
9
 
@@ -59,16 +60,16 @@ async function countLines(filePath) {
59
60
  }
60
61
  }
61
62
 
62
- async function countDependencies(projectDir) {
63
- const pkgPath = path.join(projectDir, "package.json");
64
- try {
65
- const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
66
- const deps = Object.keys(pkg.dependencies || {});
67
- const devDeps = Object.keys(pkg.devDependencies || {});
68
- return { dependencies: deps.length, devDependencies: devDeps.length, total: deps.length + devDeps.length };
69
- } catch {
70
- return { dependencies: 0, devDependencies: 0, total: 0 };
63
+ function countDependenciesFromStack(stack) {
64
+ // Suma deps + devDeps de TODOS los manifests detectados (multi-stack).
65
+ // Si no hay manifests, devuelve ceros — coherente con repos sin paquete.
66
+ let deps = 0;
67
+ let devDeps = 0;
68
+ for (const m of stack.manifests) {
69
+ deps += m.deps;
70
+ devDeps += m.devDeps;
71
71
  }
72
+ return { dependencies: deps, devDependencies: devDeps, total: deps + devDeps };
72
73
  }
73
74
 
74
75
  function runDepcheck(projectDir) {
@@ -231,8 +232,11 @@ export async function measureBasalCost(projectDir) {
231
232
  totalLines += await countLines(f);
232
233
  }
233
234
 
234
- const depInfo = await countDependencies(projectDir);
235
- const depcheck = await runDepcheck(projectDir);
235
+ const stack = summariseStack(await sniffManifests(projectDir));
236
+ const depInfo = countDependenciesFromStack(stack);
237
+ // depcheck solo aplica a Node; en stacks puros no-Node lo saltamos.
238
+ const hasNode = stack.manifests.some((m) => m.stack === "node");
239
+ const depcheck = hasNode ? await runDepcheck(projectDir) : { unused: [], note: "depcheck skipped (no package.json)" };
236
240
  const deadExports = await findDeadExports(sourceFiles);
237
241
 
238
242
  return {
@@ -240,7 +244,8 @@ export async function measureBasalCost(projectDir) {
240
244
  totalFiles: sourceFiles.length,
241
245
  dependencies: depInfo,
242
246
  unusedDependencies: depcheck,
243
- deadExports
247
+ deadExports,
248
+ stack
244
249
  };
245
250
  }
246
251
 
@@ -0,0 +1,51 @@
1
+ // KJC-TSK-0470 — Bootstrap Docker de ai-harness-scorecard.
2
+ // One-shot container: detect Docker, auto-pull on first run (cache hit
3
+ // thereafter), execute `assess <repo>` with the project mounted read-only.
4
+ // No compose, no port discovery — assess is a single process that exits.
5
+ import { runCommand } from "../utils/process.js";
6
+
7
+ const D = { image: "markmishaev76/ai-harness-scorecard:latest", pullTimeoutMs: 300000, assessTimeoutMs: 180000 };
8
+ const pos = (v, d) => (Number(v) > 0 ? Number(v) : d);
9
+
10
+ export function normalizeHarnessConfig(h = {}) {
11
+ return {
12
+ enabled: h.enabled !== false,
13
+ image: h.image || D.image,
14
+ pullTimeoutMs: pos(h.pull_timeout_ms, D.pullTimeoutMs),
15
+ assessTimeoutMs: pos(h.assess_timeout_ms, D.assessTimeoutMs),
16
+ };
17
+ }
18
+
19
+ export async function isDockerAvailable() {
20
+ try {
21
+ const r = await runCommand("docker", ["version", "--format", "{{.Server.Version}}"], { timeout: 10000 });
22
+ return r.exitCode === 0;
23
+ } catch { return false; }
24
+ }
25
+
26
+ export async function isHarnessImagePresent(image) {
27
+ const r = await runCommand("docker", ["images", "-q", image], { timeout: 10000 });
28
+ return r.exitCode === 0 && r.stdout.trim().length > 0;
29
+ }
30
+
31
+ export async function ensureHarnessImage(harness = null) {
32
+ const cfg = normalizeHarnessConfig(harness || {});
33
+ if (await isHarnessImagePresent(cfg.image)) return { pulled: false, reused: true, image: cfg.image };
34
+ const r = await runCommand("docker", ["pull", cfg.image], { timeout: cfg.pullTimeoutMs });
35
+ if (r.exitCode !== 0) return { pulled: false, reused: false, image: cfg.image, error: r.stderr?.trim() || `exit ${r.exitCode}` };
36
+ return { pulled: true, reused: false, image: cfg.image };
37
+ }
38
+
39
+ export async function runHarnessAssess(repoPath, harness = null) {
40
+ const cfg = normalizeHarnessConfig(harness || {});
41
+ const r = await runCommand(
42
+ "docker",
43
+ ["run", "--rm", "-v", `${repoPath}:/repo:ro`, cfg.image, "assess", "/repo", "--format", "json"],
44
+ { timeout: cfg.assessTimeoutMs },
45
+ );
46
+ if (r.exitCode !== 0) return { ok: false, error: r.stderr?.trim() || r.stdout?.trim() || `exit ${r.exitCode}` };
47
+ try {
48
+ const parsed = JSON.parse(r.stdout);
49
+ return { ok: true, score: parsed.overall_score, grade: parsed.grade, raw: parsed };
50
+ } catch (e) { return { ok: false, error: `Invalid JSON from harness: ${e.message}` }; }
51
+ }
@@ -0,0 +1,77 @@
1
+ // KJC-TSK-0471 — Harness Scorecard audit stage.
2
+ // Runs the markmishaev76/ai-harness-scorecard container after the deterministic
3
+ // phase, persists scorecard.json + scorecard-report.md under
4
+ // .karajan/audit-runs/<ts>/, and returns a summary the renderer surfaces as a
5
+ // "Harness Health Score" markdown block. KJC-TSK-0472 will consume the
6
+ // persisted artefacts to build audit-history.db.
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { normalizeHarnessConfig, runHarnessAssess } from "./harness-scorecard.js";
10
+
11
+ const CATEGORIES = [
12
+ ["architectural_docs", "Architectural Docs", 20],
13
+ ["mechanical_constraints", "Mechanical Constraints", 25],
14
+ ["testing_stability", "Testing & Stability", 25],
15
+ ["review_drift_prevention", "Review & Drift Prevention", 15],
16
+ ["ai_specific_safeguards", "AI-Specific Safeguards", 15],
17
+ ];
18
+ const labelFor = (k) => CATEGORIES.find(([key]) => key === k)?.[1] || k;
19
+ const ts = () => new Date().toISOString().replaceAll(/[:.]/g, "-");
20
+
21
+ export async function runHarnessSection({ projectDir, harnessConfig = null, runAssess = runHarnessAssess } = {}) {
22
+ const cfg = normalizeHarnessConfig(harnessConfig || {});
23
+ if (!cfg.enabled) return { ok: true, skipped: true, reason: "disabled" };
24
+ const res = await runAssess(projectDir, cfg);
25
+ if (!res.ok) return { ok: false, error: res.error };
26
+ const outDir = path.join(projectDir, ".karajan", "audit-runs", ts());
27
+ await fs.mkdir(outDir, { recursive: true });
28
+ const scorecardPath = path.join(outDir, "scorecard.json");
29
+ const rawReportPath = path.join(outDir, "scorecard-report.md");
30
+ await fs.writeFile(scorecardPath, JSON.stringify(res.raw, null, 2), "utf8");
31
+ await fs.writeFile(rawReportPath, res.raw?.report_markdown || res.raw?.markdown || "", "utf8");
32
+ return {
33
+ ok: true, score: res.score, grade: res.grade,
34
+ categories: res.raw?.categories || {},
35
+ checks: Array.isArray(res.raw?.checks) ? res.raw.checks : [],
36
+ scorecardPath, rawReportPath, image: cfg.image,
37
+ };
38
+ }
39
+
40
+ export function formatHarnessSection(summary) {
41
+ if (!summary || summary.skipped) return "";
42
+ if (!summary.ok) return `## Harness Scorecard\n\n_skipped: ${summary.error}_\n`;
43
+ const lines = [
44
+ `## Harness Health Score: ${summary.score}/100 (${summary.grade})`,
45
+ "",
46
+ "| Category | Weight | Score |",
47
+ "| --- | --- | --- |",
48
+ ];
49
+ for (const [key, label, weight] of CATEGORIES) {
50
+ const score = summary.categories?.[key]?.score ?? "—";
51
+ lines.push(`| ${label} | ${weight}% | ${score} |`);
52
+ }
53
+ const checks = Array.isArray(summary.checks) ? summary.checks : [];
54
+ if (checks.length) {
55
+ const groups = new Map();
56
+ for (const c of checks) {
57
+ const k = c.category || "other";
58
+ if (!groups.has(k)) groups.set(k, []);
59
+ groups.get(k).push(c);
60
+ }
61
+ lines.push("", "### Checks");
62
+ for (const [k, list] of groups) {
63
+ lines.push(`- **${labelFor(k)}**`);
64
+ for (const c of list) lines.push(` - ${c.passed ? "✓" : "✗"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}`);
65
+ }
66
+ }
67
+ return lines.join("\n") + "\n";
68
+ }
69
+
70
+ export function harnessSummaryForJson(summary) {
71
+ if (!summary?.ok || summary.skipped) return null;
72
+ return {
73
+ score: summary.score, grade: summary.grade,
74
+ categories: summary.categories, checks: summary.checks,
75
+ rawReportPath: summary.rawReportPath, scorecardPath: summary.scorecardPath,
76
+ };
77
+ }
@@ -0,0 +1,41 @@
1
+ // KJC-TSK-0470 — `kj doctor` check for the AI Harness Scorecard image.
2
+ // Reports Disabled / Docker missing / Image not pulled / OK. The image
3
+ // is pulled lazily by `kj audit` on first use; this check is informational.
4
+ import { isDockerAvailable, isHarnessImagePresent, normalizeHarnessConfig } from "../audit/harness-scorecard.js";
5
+ import { STRATEGY } from "./types.js";
6
+
7
+ function harnessEnabled(config) { return config?.audit?.harness?.enabled !== false; }
8
+
9
+ function createHarnessScorecardCheck() {
10
+ return {
11
+ name: "harness-scorecard",
12
+ label: "AI Harness Scorecard (audit)",
13
+ strategy: STRATEGY.NONE,
14
+ async detect({ config }) {
15
+ const cfg = normalizeHarnessConfig(config?.audit?.harness || {});
16
+ if (!harnessEnabled(config)) {
17
+ return { ok: true, severity: "info", detail: "Disabled in config (audit.harness.enabled=false)" };
18
+ }
19
+ let docker; try { docker = await isDockerAvailable(); } catch { docker = false; }
20
+ if (!docker) return {
21
+ ok: false, severity: "warn",
22
+ detail: "Docker not available — kj audit will skip the harness step.",
23
+ fix: "Install Docker (or run `kj audit --no-harness` to silence this).",
24
+ extra: { docker: false, image: cfg.image },
25
+ };
26
+ let cached; try { cached = await isHarnessImagePresent(cfg.image); } catch { cached = false; }
27
+ if (!cached) return {
28
+ ok: true, severity: "info",
29
+ detail: `Will be pulled on first \`kj audit\` run (${cfg.image}).`,
30
+ extra: { docker: true, cached: false, image: cfg.image },
31
+ };
32
+ return {
33
+ ok: true, severity: "info",
34
+ detail: `Image cached locally (${cfg.image}).`,
35
+ extra: { docker: true, cached: true, image: cfg.image },
36
+ };
37
+ },
38
+ };
39
+ }
40
+
41
+ export function getHarnessScorecardChecks() { return [createHarnessScorecardCheck()]; }
@@ -3,7 +3,7 @@ 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
7
  import { watchStartCommand, watchStopCommand, watchStatusCommand } from "../commands/watch.js";
8
8
  import { auditCommand } from "../commands/audit.js";
9
9
  import { resumeCommand } from "../commands/resume.js";
@@ -104,12 +104,21 @@ export function registerMeta(program, { pkgVersion }) {
104
104
  rag.command("index")
105
105
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
106
106
  .option("--with-sources", "Also index the projectDir's JS/TS files")
107
+ .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
108
  .option("--json", "Output the totals as JSON")
108
109
  .action(async (flags) => {
109
110
  await withConfig(pkgVersion, "rag-index", flags, async ({ config, logger }) => {
110
111
  await ragIndexCommand({ config, logger, flags });
111
112
  });
112
113
  });
114
+ rag.command("install-hooks")
115
+ .description("Install the post-merge git hook so the RAG index auto-refreshes after pulls/merges (KJC-TSK-0455)")
116
+ .option("--json", "Output the result as JSON")
117
+ .action(async (flags) => {
118
+ await withConfig(pkgVersion, "rag-install-hooks", flags, async ({ config, logger }) => {
119
+ await ragInstallHooksCommand({ config, logger, flags });
120
+ });
121
+ });
113
122
  rag.command("query <text>")
114
123
  .description("Run a semantic query against the indexed RAG corpus")
115
124
  .option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
@@ -136,6 +145,20 @@ export function registerMeta(program, { pkgVersion }) {
136
145
  await ragQueryCommand({ text, config, logger, flags: { ...flags, mode: ragMode } });
137
146
  });
138
147
  });
148
+ // KJC-TSK-0483 — `kj rag eval` runs the golden-query harness and reports
149
+ // recall@5/recall@10/MRR. `--min-recall` turns it into a CI gate.
150
+ rag.command("eval")
151
+ .description("Score the retriever against a golden query set (recall@k + MRR)")
152
+ .option("--golden <path>", "Path to golden queries JSON (default: tests/rag/golden-queries.json)")
153
+ .option("--top-k <n>", "Number of hits to retrieve per query (default: 10)", "10")
154
+ .option("--min-recall <n>", "Fail (exit 1) if aggregated recall@5 falls below this threshold")
155
+ .option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project.")
156
+ .option("--json", "Emit the full report as JSON")
157
+ .action(async (flags) => {
158
+ await withConfig(pkgVersion, "rag-eval", flags, async ({ config, logger }) => {
159
+ await ragEvalCommand({ config, logger, flags });
160
+ });
161
+ });
139
162
 
140
163
  // KJC-TSK-0441 — `kj watch [start|stop|status]` for live RAG re-index.
141
164
  const watch = program.command("watch").description("Live RAG re-index daemon");
@@ -161,6 +184,8 @@ export function registerMeta(program, { pkgVersion }) {
161
184
  .option("--no-sonar", "Skip the SonarQube findings collector (faster, less context). Sonar findings are also skipped automatically when SonarQube is unreachable.")
162
185
  .option("--no-osv", "Skip the OSV-Scanner vulnerability collector. Findings are also skipped automatically when osv-scanner is not installed.")
163
186
  .option("--no-semgrep", "Skip the Semgrep SAST collector. Findings are also skipped automatically when semgrep is not installed (install via 'pipx install semgrep' or 'brew install semgrep').")
187
+ .option("--no-harness", "Skip the AI Harness Scorecard stage (Docker one-shot). Auto-skipped when Docker is unavailable. Each run is also persisted to .karajan/audit-history.db (KJC-TSK-0472) for diff/trend tracking.")
188
+ .option("--trend", "Append a sparkline of the harness score across the last 10 runs (uses .karajan/audit-history.db). KJC-TSK-0473.")
164
189
  .option("--report-file <path>", "Write the audit report to disk in addition to stdout. <path> may be a file (extension drives format: .md or .json) or a directory (creates audit-<ISO>.<md|json> inside). $KJ_AUDIT_REPORT_DIR env var is used as default directory if no --report-file is given.")
165
190
  .option("--deterministic-only", "Skip the LLM analysis entirely. Print/persist only the deterministic findings (basalCost, sonar, stack, growth-delta, webperf). Zero tokens spent. Compatible with --report-file and --json.")
166
191
  .option("-y, --yes", "Auto-confirm the 'Continue with LLM analysis?' prompt. Useful in scripts that want the full audit non-interactively. CI/non-TTY paths already auto-confirm without this flag.")
@@ -182,9 +207,11 @@ export function registerMeta(program, { pkgVersion }) {
182
207
  noSonar: flags.sonar === false,
183
208
  noOsv: flags.osv === false,
184
209
  noSemgrep: flags.semgrep === false,
210
+ noHarness: flags.harness === false,
185
211
  reportFile: flags.reportFile || null,
186
212
  deterministicOnly: Boolean(flags.deterministicOnly),
187
213
  yes: Boolean(flags.yes),
214
+ trend: Boolean(flags.trend),
188
215
  });
189
216
  });
190
217
  });
@@ -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)")