karajan-code 2.31.0 → 2.33.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,6 +345,22 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **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.
349
+ >
350
+ > - **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.
351
+ > - **`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.
352
+ > - **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.
353
+ > - **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.
354
+ >
355
+ > **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.
356
+ >
357
+ > - **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.
358
+ > - **Coverage v8 + CI artifact** (PR #870, KJC-TSK-0465): `vitest.config.js` emits `text + html + lcov` via `@vitest/coverage-v8`. New `coverage` job uploads `coverage/` (14-day retention). Per-glob thresholds enforced when the user opts in; `src/mcp/handlers/**` floor ratcheted to `70/60` to lock the current state — follow-up tracked to climb back to 80/80.
359
+ > - **Conventional Commits on PR head** (PR #872, KJC-TSK-0466): `wagoid/commitlint-github-action@v6` checks every PR commit against `.commitlintrc.json`. CI-side enforcement on top of the pre-commit local hook — bypassing husky no longer escapes the gate.
360
+ > - **Nightly drift workflow** (PR #873, KJC-TSK-0467): new `.github/workflows/nightly.yml` runs the full CI suite every night at 04:17 UTC against `main`. Failures auto-file/update a tracking issue tagged `drift` via `actions/github-script@v8`, so a flaky dep or upstream regression surfaces within 24 h instead of on the next unrelated PR.
361
+ > - **`eslint-plugin-security` policy** (PR #874, KJC-TSK-0468): `eslint.config.js` now blocks `eval`, `new Function`, dynamic `require`, `pseudoRandomBytes` and `mustache`-escape disabling as hard errors; flags `detect-non-literal-regexp` as warn. Noisy members of the recommended preset are intentionally NOT enabled.
362
+ > - **42 tests on `main` repaired** (PR #869, KJC-BUG-0065) and **`await openEditor` race in spec-reviewer refine-loop fixed** (PR #871, KJC-BUG-0066). The hardening sprint sits on a clean `main` again.
363
+ >
348
364
  > **v2.31.0 released** — Minor. **Team-shared HU Board** — the full KJC-PRP-0002: opt-in HUs into a `.karajan-shared/` cohort, the board surfaces them with a `shared` badge, and per-HU `assignee` lets multiple machines work the same plan without trampling each other.
349
365
  >
350
366
  > - **`kj plan share` / `kj plan unshare`** (PRs #860, #862): mueve plans entre `~/.karajan/plans/` (local) y `<projectDir>/.karajan-shared/plans/` (compartido). Idempotente. El loader probará ambos automáticamente.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.31.0",
3
+ "version": "2.33.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",
@@ -55,9 +55,12 @@
55
55
  "setup": "node scripts/install.js",
56
56
  "test": "vitest run",
57
57
  "test:watch": "vitest",
58
+ "test:coverage": "vitest run --coverage",
58
59
  "lint": "eslint src/",
59
60
  "lint:fix": "eslint src/ --fix",
60
61
  "lint:syntax": "((command -v rg >/dev/null 2>&1 && rg --files src tests | rg '\\.js$') || find src tests -type f -name '*.js') | while IFS= read -r f; do node --check \"$f\"; done",
62
+ "format:check": "prettier --check .",
63
+ "format:fix": "prettier --write .",
61
64
  "typecheck": "npx -p typescript@5 tsc --noEmit -p tsconfig.json",
62
65
  "validate": "npm run lint && npm run lint:syntax && npm test",
63
66
  "mcp": "node src/mcp/server.js"
@@ -90,9 +93,11 @@
90
93
  "esbuild": "^0.28.0",
91
94
  "eslint": "^10.2.1",
92
95
  "eslint-plugin-import-x": "^4.16.2",
96
+ "eslint-plugin-security": "^4.0.0",
93
97
  "globals": "^17.5.0",
94
98
  "lint-staged": "^16.4.0",
95
99
  "postject": "^1.0.0-alpha.6",
100
+ "prettier": "^3.4.2",
96
101
  "simple-git-hooks": "^2.13.1",
97
102
  "vitest": "^4.0.0"
98
103
  }
@@ -3352,7 +3352,10 @@ async function showConfigEditor(scope = 'global') {
3352
3352
  <span style="font-size:0.85rem;color:var(--text-muted);">${f.value ? 'activado' : 'desactivado'}</span>
3353
3353
  </label>`;
3354
3354
  } else if (f.type === 'number') {
3355
- input = `<input type="number" id="${id}" data-key="${esc(f.key)}" value="${esc(String(f.value))}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''} style="width:120px;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">`;
3355
+ // KJC-BUG-0069 — honour the schema's `step` (e.g. 'any' for floats like
3356
+ // rag.search.alpha=0.6). HTML5 number inputs default to step=1, which
3357
+ // marks any fractional value as stepMismatch and refuses to commit.
3358
+ input = `<input type="number" id="${id}" data-key="${esc(f.key)}" value="${esc(String(f.value))}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}${f.step != null ? ` step="${esc(String(f.step))}"` : ''} style="width:120px;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">`;
3356
3359
  }
3357
3360
  return `
3358
3361
  <div style="margin-bottom:14px;">
@@ -235,11 +235,10 @@ export const EDITABLE_FIELDS = [
235
235
  help: 'Orquestador IA central: enriquece feedback, comprime outputs y verifica cambios reales. Opt-in.',
236
236
  default: false,
237
237
  },
238
- // v2.30.0 — RAG controls. Sólo se exponen las claves que el código
239
- // realmente lee hoy (rag.preload.* en rag-context-stage.js y
240
- // rag.embedder.provider en embedders/factory.js). mode/alpha/rerank
241
- // se controlan vía flags CLI; cuando se cableen en config se añadirán
242
- // aquí (ver task #99).
238
+ // v2.30.0 — RAG controls. Cubren los tres ejes que afectan al
239
+ // retrieval en tiempo de pipeline: pre-carga (rag.preload.*),
240
+ // proveedor de embeddings (rag.embedder.provider) y parámetros de
241
+ // búsqueda (rag.search.*, cableados al stage en KJC-TSK-0463).
243
242
  {
244
243
  key: 'ragPreloadEnabled',
245
244
  path: 'rag.preload.enabled',
@@ -280,6 +279,42 @@ export const EDITABLE_FIELDS = [
280
279
  options: ['ollama', 'openai', 'voyage', 'cohere', 'mistral', 'onnx'],
281
280
  default: 'ollama',
282
281
  },
282
+ // KJC-TSK-0463 — search-time knobs ahora cableados al stage.
283
+ // mode/alpha controlan el fuse semantic vs keyword; rerank activa el
284
+ // cross-encoder opcional (más calidad, más latencia).
285
+ {
286
+ key: 'ragSearchMode',
287
+ path: 'rag.search.mode',
288
+ category: 'rag',
289
+ type: 'select',
290
+ label: 'Modo de búsqueda RAG',
291
+ help: '"hybrid" mezcla semántico + BM25 (recomendado). "semantic" sólo embeddings. "keyword" sólo BM25 (útil si los embeddings están desafinados).',
292
+ options: ['hybrid', 'semantic', 'keyword'],
293
+ default: 'hybrid',
294
+ },
295
+ {
296
+ key: 'ragSearchAlpha',
297
+ path: 'rag.search.alpha',
298
+ category: 'rag',
299
+ type: 'number',
300
+ label: 'Alpha del modo hybrid (0-1)',
301
+ help: 'Peso del scoring semántico vs keyword en modo hybrid. 1.0 = sólo semántico, 0.0 = sólo BM25, 0.6 (default) = inclinado a semántico.',
302
+ min: 0,
303
+ max: 1,
304
+ // KJC-BUG-0069 — number input defaults to step=1 (HTML5), so any
305
+ // non-integer default fails validity. Allow any fractional value here.
306
+ step: 'any',
307
+ default: 0.6,
308
+ },
309
+ {
310
+ key: 'ragSearchRerank',
311
+ path: 'rag.search.rerank',
312
+ category: 'rag',
313
+ type: 'boolean',
314
+ label: 'Activar rerank cross-encoder',
315
+ help: 'Re-puntúa los topK supervivientes con un cross-encoder. Mejora calidad pero añade latencia y requiere modelo extra. Opt-in.',
316
+ default: false,
317
+ },
283
318
  // KJC-PRP-0002 PR5: when the board scans plans, it visits both
284
319
  // ~/.karajan/plans/<slug>/ (per-user cache) and .karajan-shared/plans/
285
320
  // (team copy). If a planId lives in both, the second pass overwrites
@@ -24,12 +24,18 @@ router.get("/stats", (_req, res) => {
24
24
  if (!existsSync(path)) return res.json({ ok: true, initialized: false, message: "rag.db not initialized — run `kj rag index <dir>` to start indexing.", embedder: embedder() });
25
25
  try {
26
26
  const db = new Database(path, { readonly: true, fileMustExist: true });
27
+ // KJC-BUG-0068 — readonly handler cannot run the project_slug migration
28
+ // (KJC-TSK-0438). Legacy DBs created pre-v2.27 lack the column; detect
29
+ // and fall back so the dashboard renders instead of crashing with 500.
30
+ const hasProjectSlug = db.prepare("PRAGMA table_info(chunks)").all().some((c) => c.name === "project_slug");
27
31
  const total_chunks = db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n;
28
32
  const by_kind = db.prepare("SELECT kind, COUNT(*) AS n FROM chunks GROUP BY kind").all();
29
- const by_project = db.prepare("SELECT COALESCE(project_slug, '(no slug)') AS project, COUNT(*) AS n FROM chunks GROUP BY project_slug ORDER BY n DESC LIMIT 20").all();
33
+ const by_project = hasProjectSlug
34
+ ? db.prepare("SELECT COALESCE(project_slug, '(no slug)') AS project, COUNT(*) AS n FROM chunks GROUP BY project_slug ORDER BY n DESC LIMIT 20").all()
35
+ : [];
30
36
  const last_indexed = db.prepare("SELECT MAX(created_at) AS ts FROM chunks").get().ts;
31
37
  db.close();
32
- res.json({ ok: true, initialized: true, db_path: path, db_size_bytes: statSync(path).size, total_chunks, by_kind, by_project, last_indexed, embedder: embedder() });
38
+ res.json({ ok: true, initialized: true, db_path: path, db_size_bytes: statSync(path).size, total_chunks, by_kind, by_project, last_indexed, embedder: embedder(), schema_legacy: !hasProjectSlug });
33
39
  } catch (err) { res.status(500).json({ ok: false, error: String(err.message || err) }); }
34
40
  });
35
41
 
@@ -20,8 +20,12 @@ jobs:
20
20
  fetch-depth: 0
21
21
 
22
22
  - name: Check PR diff for AI attribution
23
+ # KJC-BUG-0072 — never interpolate ${{ github.* }} directly inside a
24
+ # `run:` block on pull_request workflows. Pin to env vars instead.
25
+ env:
26
+ BASE_REF: ${{ github.base_ref }}
23
27
  run: |
24
- DIFF=$(git diff origin/${{ github.base_ref }}...HEAD \
28
+ DIFF=$(git diff "origin/${BASE_REF}...HEAD" \
25
29
  -- ':!*.png' ':!*.jpg' ':!*.svg' ':!*.ico' \
26
30
  ':!*.lock' ':!package-lock.json' ':!yarn.lock' \
27
31
  ':!*.min.js' ':!*.min.css' \
@@ -54,8 +58,11 @@ jobs:
54
58
  echo "AI attribution guard: clean"
55
59
 
56
60
  - name: Check commit messages
61
+ # KJC-BUG-0072 — same hardening as the diff scan: env-pin the ref.
62
+ env:
63
+ BASE_REF: ${{ github.base_ref }}
57
64
  run: |
58
- COMMITS=$(git log --format='%s%n%b' origin/${{ github.base_ref }}...HEAD)
65
+ COMMITS=$(git log --format='%s%n%b' "origin/${BASE_REF}...HEAD")
59
66
 
60
67
  COAUTH='Co-Authored-By:.*\b(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)\b'
61
68
  ATTR='(Generated|Created|Written|Built|Authored) (by|with|using) .*(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)'
@@ -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,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
+
@@ -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()]; }
@@ -115,15 +115,25 @@ export function registerMeta(program, { pkgVersion }) {
115
115
  .option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
116
116
  .option("--top-k <n>", "Number of hits to return (default: 5)", "5")
117
117
  .option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project. Default: cwd basename")
118
- .option("--mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
118
+ // KJC-BUG-0070 — the global `--mode` flag maps to `review_mode` in
119
+ // src/config/overrides.js. We expose the rag-specific knob under
120
+ // `--rag-mode` to avoid the collision (which crashed the command on
121
+ // first call). Internally we rebind to `flags.mode` so the handler
122
+ // in src/commands/rag.js stays untouched.
123
+ .option("--rag-mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
119
124
  .option("--alpha <n>", "Hybrid weight for semantic component (0..1). Default 0.6", "0.6")
120
125
  .option("--where <clause>", "Metadata filter, e.g. 'symbol=loadConfig' or 'hu_id=HU-003 AND kind=plan'")
121
126
  .option("--rerank", "Apply cross-encoder rerank to top-K (needs @huggingface/transformers, slower but more precise)")
122
127
  .option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
123
128
  .option("--json", "Output the hits as JSON")
124
129
  .action(async (text, flags) => {
130
+ // Stash the rag-specific mode and remove it from the flags object so
131
+ // applyScalarAndBooleanOverrides cannot see a `flags.mode` and write
132
+ // out.review_mode = "hybrid" — that would fail the schema validator.
133
+ const ragMode = flags.ragMode;
134
+ delete flags.ragMode;
125
135
  await withConfig(pkgVersion, "rag-query", flags, async ({ config, logger }) => {
126
- await ragQueryCommand({ text, config, logger, flags });
136
+ await ragQueryCommand({ text, config, logger, flags: { ...flags, mode: ragMode } });
127
137
  });
128
138
  });
129
139
 
@@ -151,6 +161,8 @@ export function registerMeta(program, { pkgVersion }) {
151
161
  .option("--no-sonar", "Skip the SonarQube findings collector (faster, less context). Sonar findings are also skipped automatically when SonarQube is unreachable.")
152
162
  .option("--no-osv", "Skip the OSV-Scanner vulnerability collector. Findings are also skipped automatically when osv-scanner is not installed.")
153
163
  .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').")
164
+ .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.")
165
+ .option("--trend", "Append a sparkline of the harness score across the last 10 runs (uses .karajan/audit-history.db). KJC-TSK-0473.")
154
166
  .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.")
155
167
  .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.")
156
168
  .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.")
@@ -172,9 +184,11 @@ export function registerMeta(program, { pkgVersion }) {
172
184
  noSonar: flags.sonar === false,
173
185
  noOsv: flags.osv === false,
174
186
  noSemgrep: flags.semgrep === false,
187
+ noHarness: flags.harness === false,
175
188
  reportFile: flags.reportFile || null,
176
189
  deterministicOnly: Boolean(flags.deterministicOnly),
177
190
  yes: Boolean(flags.yes),
191
+ trend: Boolean(flags.trend),
178
192
  });
179
193
  });
180
194
  });
@@ -11,6 +11,9 @@ import { withCliRunLog } from "../utils/cli-run-log.js";
11
11
  import { createCliProgressReporter } from "../utils/cli-progress.js";
12
12
  import { runAgentReadiness, formatAgentReadinessReport } from "../audit/agent-readiness.js";
13
13
  import { formatDeterministicSummary } from "../audit/deterministic-summary.js";
14
+ import { runHarnessSection, formatHarnessSection, harnessSummaryForJson } from "../audit/harness-section.js";
15
+ import { persistAuditRun, openAuditHistoryDb, getLatestPreviousRun, getRecentScores } from "../audit/audit-history.js";
16
+ import { computeHistoryDiff, formatHistoryDiff, formatTrendSparkline } from "../audit/audit-history-display.js";
14
17
 
15
18
  const execFileAsync = promisify(execFile);
16
19
 
@@ -191,20 +194,43 @@ async function buildReportHeader(projectDir, invocation) {
191
194
  return lines.join("\n");
192
195
  }
193
196
 
194
- function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }) {
197
+ function composeHistoryBlock({ diffMd, trendMd }) {
198
+ const parts = [];
199
+ if (diffMd) parts.push(diffMd);
200
+ if (trendMd) parts.push(trendMd);
201
+ return parts.length ? `\n\n## Harness Score History\n${parts.join("\n\n")}` : "";
202
+ }
203
+
204
+ // KJC-TSK-0473 — Resolve diff vs previous run + optional trend sparkline.
205
+ // Errors silenced: history is a nice-to-have; a broken db must not break audit.
206
+ function loadAuditHistoryContext(projectDir, currentHarness, { showTrend = false } = {}) {
207
+ let db;
208
+ try {
209
+ db = openAuditHistoryDb(projectDir);
210
+ if (!db) return { diff: null, diffMd: "", trendMd: "" };
211
+ const previous = currentHarness?.score != null ? getLatestPreviousRun(db, new Date().toISOString()) : null;
212
+ const diff = currentHarness?.score != null ? computeHistoryDiff(currentHarness, previous) : null;
213
+ const trendMd = showTrend ? formatTrendSparkline(getRecentScores(db, 10)) : "";
214
+ return { diff, diffMd: formatHistoryDiff(diff), trendMd };
215
+ } catch { return { diff: null, diffMd: "", trendMd: "" }; }
216
+ finally { try { db?.close(); } catch { /* noop */ } }
217
+ }
218
+
219
+ function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, json, deterministicOnly, yes }) {
195
220
  const parts = ["kj audit"];
196
221
  if (task && task !== "Analyze the full codebase") parts.push(JSON.stringify(task));
197
222
  if (dimensions && dimensions !== "all") parts.push(`--dimensions=${dimensions}`);
198
223
  if (noSonar) parts.push("--no-sonar");
199
224
  if (noOsv) parts.push("--no-osv");
200
225
  if (noSemgrep) parts.push("--no-semgrep");
226
+ if (noHarness) parts.push("--no-harness");
201
227
  if (deterministicOnly) parts.push("--deterministic-only");
202
228
  if (yes) parts.push("--yes");
203
229
  if (json) parts.push("--json");
204
230
  return parts.join(" ");
205
231
  }
206
232
 
207
- export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, reportFile = null, deterministicOnly = false, yes = false, promptFn = null }) {
233
+ export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, noHarness = false, reportFile = null, deterministicOnly = false, yes = false, trend = false, promptFn = null }) {
208
234
  // --agent-readiness is a STANDALONE, deterministic, LLM-free audit
209
235
  // dimension. It scores any third-party repo for AI-agent readability
210
236
  // (llms.txt presence, page token budgets, robots allowlist, etc.).
@@ -247,6 +273,27 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
247
273
  const deterministicCtx = await role.collectDeterministic(roleInput);
248
274
  const deterministicMd = formatDeterministicSummary(deterministicCtx);
249
275
 
276
+ // KJC-TSK-0471 — Harness Scorecard (Docker one-shot, golden metric).
277
+ // Runs after the deterministic phase, before the LLM. Auto-skipped on
278
+ // --no-harness or if `runHarnessSection` surfaces an error (graceful
279
+ // degradation: a missing Docker daemon must not break `kj audit`).
280
+ let harnessSummary = null;
281
+ if (!noHarness) {
282
+ try {
283
+ harnessSummary = await runHarnessSection({
284
+ projectDir: config?.projectDir || process.cwd(),
285
+ harnessConfig: config?.audit?.harness || null,
286
+ });
287
+ if (harnessSummary?.ok && !harnessSummary.skipped) {
288
+ runLog.logText(`[audit] harness score=${harnessSummary.score}/100 grade=${harnessSummary.grade}`);
289
+ }
290
+ } catch (e) {
291
+ harnessSummary = { ok: false, error: e?.message || String(e) };
292
+ runLog.logText(`[audit] harness skipped: ${harnessSummary.error}`);
293
+ }
294
+ }
295
+ const harnessMd = formatHarnessSection(harnessSummary);
296
+
250
297
  // --json suppresses the interactive flow regardless: it would mangle
251
298
  // a script's stdout. JSON consumers always get the full audit unless
252
299
  // they explicitly pass deterministicOnly.
@@ -263,23 +310,28 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
263
310
  if (!runLlm) {
264
311
  // Deterministic-only path. Produce the report from what we already
265
312
  // have, persist it (md or json), and exit. Zero tokens spent.
313
+ const harnessJson = harnessSummaryForJson(harnessSummary);
314
+ const currentHarness = harnessSummary?.ok && !harnessSummary.skipped ? harnessSummary : null;
315
+ const history = loadAuditHistoryContext(config?.projectDir || process.cwd(), currentHarness, { showTrend: trend });
316
+ const historyMd = composeHistoryBlock(history);
266
317
  const stdoutContent = json
267
- ? JSON.stringify({ deterministic: deterministicCtx, mode: "deterministic-only" }, null, 2)
268
- : (deterministicOnly ? deterministicMd : ""); // already printed above when interactive
269
- if (json || deterministicOnly) console.log(stdoutContent);
318
+ ? JSON.stringify({ deterministic: deterministicCtx, harnessScore: harnessJson, harnessScoreDiff: history.diff, mode: "deterministic-only" }, null, 2)
319
+ : (deterministicOnly ? `${deterministicMd}\n${harnessMd}${historyMd}` : `${harnessMd}${historyMd}`);
320
+ if (json || deterministicOnly || harnessMd) console.log(stdoutContent);
270
321
 
271
322
  if (reportPath) {
272
323
  let payload;
273
324
  if (reportPath.endsWith(".json")) {
274
- payload = JSON.stringify({ deterministic: deterministicCtx, mode: "deterministic-only" }, null, 2);
325
+ payload = JSON.stringify({ deterministic: deterministicCtx, harnessScore: harnessJson, harnessScoreDiff: history.diff, mode: "deterministic-only" }, null, 2);
275
326
  } else {
276
- const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }));
277
- payload = header + deterministicMd + "\n";
327
+ const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, json, deterministicOnly, yes }));
328
+ payload = `${header}${deterministicMd}\n${harnessMd}${historyMd}`;
278
329
  }
279
330
  await fs.writeFile(reportPath, payload, "utf8");
280
331
  runLog.logText(`[audit] report written (deterministic-only) → ${reportPath}`);
281
332
  logger.info(`Audit report written: ${reportPath}`);
282
333
  }
334
+ persistAuditRun(config?.projectDir || process.cwd(), { runId: new Date().toISOString(), timestamp: new Date().toISOString(), gitSha: null, harness: harnessSummary?.ok && !harnessSummary.skipped ? harnessSummary : null, rawReportPath: reportPath });
283
335
  logger.info("Audit completed (deterministic-only).");
284
336
  return { ok: true, mode: "deterministic-only", reportPath: reportPath || undefined };
285
337
  }
@@ -306,18 +358,22 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
306
358
  runLog.logText(`[audit] usage tokens=${usage.total_tokens} cost=$${(usage.cost_usd ?? 0).toFixed(4)} duration=${(usage.durationMs / 1000).toFixed(1)}s`);
307
359
  }
308
360
 
361
+ const harnessJson = harnessSummaryForJson(harnessSummary);
362
+ const currentHarness = harnessSummary?.ok && !harnessSummary.skipped ? harnessSummary : null;
363
+ const history = loadAuditHistoryContext(config?.projectDir || process.cwd(), currentHarness, { showTrend: trend });
364
+ const historyMd = composeHistoryBlock(history);
309
365
  let stdoutContent;
310
366
  if (json) {
311
367
  const jsonPayload = parsed
312
- ? { ...parsed, usage: usage || undefined }
313
- : { ...roleResult, usage: usage || undefined };
368
+ ? { ...parsed, harnessScore: harnessJson, harnessScoreDiff: history.diff, usage: usage || undefined }
369
+ : { ...roleResult, harnessScore: harnessJson, harnessScoreDiff: history.diff, usage: usage || undefined };
314
370
  stdoutContent = JSON.stringify(jsonPayload, null, 2);
315
371
  } else if (parsed?.summary?.overallHealth) {
316
- stdoutContent = formatAudit(parsed, usage);
372
+ stdoutContent = `${formatAudit(parsed, usage)}\n${harnessMd}${historyMd}`;
317
373
  } else if (parsed?.raw) {
318
- stdoutContent = parsed.raw + (usage ? `\n\n${formatUsageSummary(usage) || ""}` : "");
374
+ stdoutContent = parsed.raw + (usage ? `\n\n${formatUsageSummary(usage) || ""}` : "") + `\n${harnessMd}${historyMd}`;
319
375
  } else {
320
- stdoutContent = roleResult.summary || "Audit complete.";
376
+ stdoutContent = `${roleResult.summary || "Audit complete."}\n${harnessMd}${historyMd}`;
321
377
  }
322
378
  console.log(stdoutContent);
323
379
 
@@ -325,11 +381,11 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
325
381
  let payload;
326
382
  if (reportPath.endsWith(".json")) {
327
383
  const jsonPayload = parsed
328
- ? { ...parsed, usage: usage || undefined }
329
- : { ...roleResult, usage: usage || undefined };
384
+ ? { ...parsed, harnessScore: harnessJson, harnessScoreDiff: history.diff, usage: usage || undefined }
385
+ : { ...roleResult, harnessScore: harnessJson, harnessScoreDiff: history.diff, usage: usage || undefined };
330
386
  payload = JSON.stringify(jsonPayload, null, 2);
331
387
  } else {
332
- const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }));
388
+ const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, json, deterministicOnly, yes }));
333
389
  payload = header + stdoutContent + "\n";
334
390
  }
335
391
  await fs.writeFile(reportPath, payload, "utf8");
@@ -337,6 +393,7 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
337
393
  logger.info(`Audit report written: ${reportPath}`);
338
394
  }
339
395
 
396
+ persistAuditRun(config?.projectDir || process.cwd(), { runId: new Date().toISOString(), timestamp: new Date().toISOString(), gitSha: null, harness: harnessSummary?.ok && !harnessSummary.skipped ? harnessSummary : null, rawReportPath: reportPath });
340
397
  logger.info("Audit completed.");
341
398
  return { ok: true, reportPath: reportPath || undefined };
342
399
  });
@@ -19,6 +19,7 @@ import { getConfigFileChecks } from "../checks/config-files.js";
19
19
  import { getBinaryChecks } from "../checks/binaries.js";
20
20
  import { getSonarChecks } from "../checks/sonar.js";
21
21
  import { getOllamaChecks } from "../checks/ollama.js";
22
+ import { getHarnessScorecardChecks } from "../checks/harness-scorecard.js";
22
23
  import { getCiChecks } from "../checks/ci.js";
23
24
  import { getRtkChecks } from "../checks/rtk.js";
24
25
  import { getNodeChecks } from "../checks/node.js";
@@ -54,6 +55,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
54
55
  ...getBinaryChecks(),
55
56
  ...getSonarChecks(),
56
57
  ...getOllamaChecks(),
58
+ ...getHarnessScorecardChecks(),
57
59
  ...getPortChecks(),
58
60
  ...getTokenChecks(config),
59
61
  ...getMcpHealthChecks(),
@@ -11,8 +11,25 @@ import { query } from "../../rag/retriever.js";
11
11
 
12
12
  const DEFAULT_TOP_K = 5;
13
13
  const DEFAULT_SCOPE = "all";
14
+ const DEFAULT_SEARCH_MODE = "hybrid";
15
+ const DEFAULT_SEARCH_ALPHA = 0.6;
14
16
  const MAX_CHUNK_CHARS = 600;
15
17
 
18
+ // KJC-TSK-0463 — pull search-time knobs from config so the HU Board
19
+ // modal (rag.search.{mode,alpha,rerank}) actually steers retrieval at
20
+ // pipeline time. Returns the args object query() expects; unset fields
21
+ // fall back to the retriever's own defaults.
22
+ export function resolveSearchOpts(config) {
23
+ const s = config?.rag?.search ?? {};
24
+ const out = {
25
+ mode: typeof s.mode === "string" ? s.mode : DEFAULT_SEARCH_MODE,
26
+ alpha: Number.isFinite(s.alpha) ? s.alpha : DEFAULT_SEARCH_ALPHA,
27
+ };
28
+ if (s.rerank && typeof s.rerank === "object") out.rerankOpts = s.rerank;
29
+ else if (s.rerank === true) out.rerankOpts = {};
30
+ return out;
31
+ }
32
+
16
33
  /**
17
34
  * Runs the RAG preload stage. Returns:
18
35
  * { skipped: true, reason } — when disabled / empty / error
@@ -36,7 +53,8 @@ export async function runRagContextStage({ config, logger, emitter, eventBase, t
36
53
  const topK = preload.topK || DEFAULT_TOP_K;
37
54
  const scope = preload.scope || DEFAULT_SCOPE;
38
55
  const embedder = makeEmbedder(config);
39
- const hits = await query(db, embedder, task, { topK, scope });
56
+ const searchOpts = resolveSearchOpts(config);
57
+ const hits = await query(db, embedder, task, { topK, scope, ...searchOpts });
40
58
  if (hits.length === 0) return { skipped: true, reason: "no-hits" };
41
59
  const lines = hits.map((h) => {
42
60
  const label = h.metadata?.hu_id || h.metadata?.symbol || h.metadata?.headingPath?.join(" > ") || "block";
@@ -46,7 +64,7 @@ export async function runRagContextStage({ config, logger, emitter, eventBase, t
46
64
  const contextBlock = `## Prior context from RAG (auto-retrieved, top ${hits.length})\n\n${lines.join("\n\n")}`;
47
65
  emitProgress(emitter, makeEvent("rag-preload:hits", { ...eventBase, stage: "rag-preload" }, {
48
66
  message: `RAG preload: injected ${hits.length} chunk(s) into the task context`,
49
- detail: { topK, scope, hits: hits.length },
67
+ detail: { topK, scope, hits: hits.length, mode: searchOpts.mode, alpha: searchOpts.alpha, rerank: Boolean(searchOpts.rerankOpts) },
50
68
  }));
51
69
  return { skipped: false, contextBlock, hits };
52
70
  } finally {
@@ -50,7 +50,7 @@ export async function runRefinePass({ spec, findings, role, projectDir, taskFile
50
50
  } catch (err) { return { ok: false, error: `persist failed: ${err.message}`, hashChanged: false }; }
51
51
 
52
52
  const before = sha256(refineRes.refinedSpec);
53
- openEditor(v2Path, { logger, openEditorFn });
53
+ await openEditor(v2Path, { logger, openEditorFn });
54
54
  let after;
55
55
  try { after = await _fs.readFile(v2Path, "utf8"); }
56
56
  catch (err) { return { ok: false, error: `read v2 failed: ${err.message}`, hashChanged: false }; }