karajan-code 2.31.0 → 2.32.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,15 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **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
+ >
350
+ > - **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.
351
+ > - **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.
352
+ > - **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.
353
+ > - **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.
354
+ > - **`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.
355
+ > - **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.
356
+ >
348
357
  > **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
358
  >
350
359
  > - **`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.32.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)'
@@ -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
 
@@ -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 }; }