karajan-code 3.4.0 → 3.4.1

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 (52) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/packages/hu-board/src/routes/api.js +70 -49
  4. package/packages/hu-board/src/zombie-reaper.js +1 -1
  5. package/src/agents/aider-agent.js +1 -1
  6. package/src/agents/gemini-agent.js +1 -1
  7. package/src/agents/opencode-agent.js +1 -1
  8. package/src/audit/agent-readiness.js +2 -1
  9. package/src/audit/deterministic-summary.js +11 -5
  10. package/src/audit/issue-filter.js +3 -6
  11. package/src/audit/plan-adherence.js +3 -1
  12. package/src/audit/semgrep-findings.js +5 -1
  13. package/src/brain/agent-error-classifier.js +8 -1
  14. package/src/brain/decisor.js +2 -2
  15. package/src/brain/solomon-consult.js +2 -2
  16. package/src/brain/standby-scheduler.js +0 -2
  17. package/src/commands/audit.js +8 -3
  18. package/src/commands/board.js +1 -1
  19. package/src/commands/standby.js +4 -1
  20. package/src/config/loader.js +2 -2
  21. package/src/golden/asserter.js +3 -1
  22. package/src/golden/schema.js +1 -1
  23. package/src/hu/auto-generator.js +1 -1
  24. package/src/lang/tree-sitter-loader.js +1 -5
  25. package/src/mcp/handlers/management-handlers.js +4 -3
  26. package/src/orchestrator/drivers/post-loop.js +43 -91
  27. package/src/orchestrator/flow-runner.js +25 -0
  28. package/src/orchestrator/fs-leak-detector.js +6 -1
  29. package/src/orchestrator/preflight-checks.js +51 -0
  30. package/src/orchestrator/solomon-rules.js +12 -1
  31. package/src/prompts/audit.js +12 -11
  32. package/src/prompts/hu-reviewer.js +1 -1
  33. package/src/prompts/planner.js +1 -1
  34. package/src/rag/embedders/factory.js +0 -1
  35. package/src/rag/retriever.js +1 -1
  36. package/src/roles/agent-role.js +1 -1
  37. package/src/roles/audit-role.js +10 -2
  38. package/src/session/journal/write-all.js +89 -0
  39. package/src/sonar/discovery.js +2 -1
  40. package/src/sonar/scanner.js +1 -1
  41. package/src/utils/cli-progress.js +4 -2
  42. package/src/utils/escape-regexp.js +15 -0
  43. package/src/utils/paths.js +0 -1
  44. package/src/utils/port-occupant.js +2 -1
  45. package/src/utils/project-detect.js +1 -1
  46. package/node_modules/better-sqlite3/build/Release/better_sqlite3.node +0 -0
  47. package/node_modules/deep-extend/CHANGELOG.md +0 -46
  48. package/node_modules/sqlite-vec/index.cjs +0 -46
  49. package/node_modules/sqlite-vec/index.d.ts +0 -17
  50. package/node_modules/sqlite-vec/index.mjs +0 -47
  51. package/node_modules/sqlite-vec-linux-x64/vec0.so +0 -0
  52. package/node_modules/which/CHANGELOG.md +0 -166
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v3.4.0 released** — Cache-friendly prompts (epic KJC-PCS-0057 / Phase 1 closed). Every Karajan prompt is now split into a stable block (identical across iterations and HUs, rendered first so automatic prefix caching hits on it) and a volatile tail; on Claude the stable block ships via `--append-system-prompt` so the CLI's cache breakpoints serve it from cache on every call. Real data: cold-run cache_pct jumps **47.2% → 99.6%** and coder cost drops **76%** ($0.61 → $0.14 per HU). A prefix-stability regression suite freezes the contract in CI. Drop-in upgrade from v3.3.0. Full notes in [CHANGELOG.md](CHANGELOG.md).
26
+ > **v3.4.1 released** — Post-Phase-1 hardening. The session journal (Cache hits, budget, commits) is now written on EVERY run ending — not just approved ones; the audit role reports its cached tokens (it was the most expensive role and reported 0); and a new preflight check fails fast with an actionable message when Sonar cannot derive a project key, instead of burning coder iterations and dying in `sonar_repeat`. Plus the top of the v3.4.0 self-audit: shared `escapeRegExp()`, 23 dead exports pruned, 4 complex regexes decomposed, and the HU Board API with zero sync fs calls + mtime-cached plan scans. Full notes in [CHANGELOG.md](CHANGELOG.md).
27
27
 
28
28
  You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
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",
@@ -1,5 +1,26 @@
1
1
  import { Router } from 'express';
2
2
  import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+
5
+ /** Async existence probe (KJC-TSK-0539 — no sync fs in handlers). */
6
+ const pathExists = (p) => fsp.access(p).then(() => true, () => false);
7
+
8
+ /**
9
+ * mtime-keyed cache for parsed plan JSON (KJC-TSK-0539). The plan scans
10
+ * used to re-read and re-parse every plan file on EVERY request; with N
11
+ * plans and the board polling, that was O(N) parses per poll. The cache
12
+ * serves the parsed object while the file's mtime is unchanged and
13
+ * re-reads transparently when kj writes the plan.
14
+ */
15
+ const planFileCache = new Map();
16
+ export async function readPlanCached(p) {
17
+ const { mtimeMs } = await fsp.stat(p);
18
+ const hit = planFileCache.get(p);
19
+ if (hit && hit.mtimeMs === mtimeMs) return hit.plan;
20
+ const plan = JSON.parse(await fsp.readFile(p, 'utf8'));
21
+ planFileCache.set(p, { mtimeMs, plan });
22
+ return plan;
23
+ }
3
24
  import os from 'node:os';
4
25
  import path from 'node:path';
5
26
  import { spawn as spawnChild } from 'node:child_process';
@@ -74,10 +95,10 @@ function huStoriesDir() {
74
95
  /**
75
96
  * Best-effort removal of the hu-stories/<id>/ directory.
76
97
  */
77
- function removeBatchDir(batchId) {
98
+ async function removeBatchDir(batchId) {
78
99
  try {
79
100
  const dir = path.join(huStoriesDir(), batchId);
80
- fs.rmSync(dir, { recursive: true, force: true });
101
+ await fsp.rm(dir, { recursive: true, force: true });
81
102
  return true;
82
103
  } catch {
83
104
  return false;
@@ -207,10 +228,10 @@ router.get('/projects/:id/preflight', async (req, res) => {
207
228
  try {
208
229
  outer: for (const root of getHuBoardPlansDirs()) {
209
230
  const plansDir = path.join(root, projectId);
210
- if (!fs.existsSync(plansDir)) continue;
211
- for (const f of fs.readdirSync(plansDir).filter((x) => x.endsWith('.json'))) {
231
+ if (!(await pathExists(plansDir))) continue;
232
+ for (const f of (await fsp.readdir(plansDir)).filter((x) => x.endsWith('.json'))) {
212
233
  try {
213
- const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
234
+ const plan = await readPlanCached(path.join(plansDir, f));
214
235
  if (plan?.projectDir) { projectDir = plan.projectDir; break outer; }
215
236
  } catch { /* try next file */ }
216
237
  }
@@ -231,7 +252,7 @@ router.get('/projects/:id/preflight', async (req, res) => {
231
252
  * failed · 15 min" banner. Plans that haven't finished yet have a
232
253
  * null outcome — the front handles that by hiding the banner.
233
254
  */
234
- router.get('/projects/:id/plans-outcome', (req, res) => {
255
+ router.get('/projects/:id/plans-outcome', async (req, res) => {
235
256
  try {
236
257
  const projectId = req.params.id;
237
258
  // KJC-BUG-0059 (v2.19.3): scan canonical + legacy roots so users
@@ -239,10 +260,10 @@ router.get('/projects/:id/plans-outcome', (req, res) => {
239
260
  const out = [];
240
261
  for (const root of getHuBoardPlansDirs()) {
241
262
  const plansDir = path.join(root, projectId);
242
- if (!fs.existsSync(plansDir)) continue;
243
- for (const f of fs.readdirSync(plansDir).filter(n => n.endsWith('.json'))) {
263
+ if (!(await pathExists(plansDir))) continue;
264
+ for (const f of (await fsp.readdir(plansDir)).filter(n => n.endsWith('.json'))) {
244
265
  try {
245
- const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
266
+ const plan = await readPlanCached(path.join(plansDir, f));
246
267
  if (plan?.version !== 2) continue;
247
268
  out.push({
248
269
  planId: plan.planId,
@@ -363,7 +384,7 @@ router.get('/sessions', (_req, res) => {
363
384
  * Also removes the hu-stories/<id>/ directory from disk so the next sync
364
385
  * does not re-import it.
365
386
  */
366
- router.delete('/projects/:id', (req, res) => {
387
+ router.delete('/projects/:id', async (req, res) => {
367
388
  try {
368
389
  const id = req.params.id;
369
390
  // Gather filesystem paths to remove BEFORE deleting DB rows so we
@@ -397,7 +418,7 @@ router.delete('/projects/:id', (req, res) => {
397
418
  // Best-effort fs cleanup; failure does NOT undo the tombstones.
398
419
  let pathsRemoved = 0;
399
420
  for (const p of fsPaths) {
400
- try { fs.rmSync(p, { recursive: true, force: true }); pathsRemoved++; } catch { /* */ }
421
+ try { await fsp.rm(p, { recursive: true, force: true }); pathsRemoved++; } catch { /* */ }
401
422
  }
402
423
  res.json({ deleted: true, pathsRemoved, tombstoned: 1 + storyIds.length + sessionIds.length });
403
424
  } catch (err) {
@@ -411,7 +432,7 @@ router.delete('/projects/:id', (req, res) => {
411
432
  * prompt id so a stale chokidar event can't recreate it, and emits a
412
433
  * `prompt:resolved` event so all open boards close the modal.
413
434
  */
414
- router.delete('/prompts/:promptId', (req, res) => {
435
+ router.delete('/prompts/:promptId', async (req, res) => {
415
436
  try {
416
437
  const promptId = req.params.promptId;
417
438
  const dir = promptsDir();
@@ -419,7 +440,7 @@ router.delete('/prompts/:promptId', (req, res) => {
419
440
  const answer = path.join(dir, `${promptId}.answer.json`);
420
441
  let removed = 0;
421
442
  for (const p of [main, answer]) {
422
- try { fs.unlinkSync(p); removed++; } catch { /* missing is fine */ }
443
+ try { await fsp.unlink(p); removed++; } catch { /* missing is fine */ }
423
444
  }
424
445
  addTombstone('prompt', promptId, { source: 'api', fsPaths: [main, answer] });
425
446
  // Re-use the same event the runner emits when answered, so any open
@@ -439,7 +460,7 @@ router.delete('/prompts/:promptId', (req, res) => {
439
460
  * it. Pre-tombstones this endpoint was a DB-only soft hide that the next
440
461
  * chokidar event undid silently.
441
462
  */
442
- router.delete('/stories/:id', (req, res) => {
463
+ router.delete('/stories/:id', async (req, res) => {
443
464
  try {
444
465
  const id = req.params.id;
445
466
  const dir = path.join(huStoriesDir(), id);
@@ -447,7 +468,7 @@ router.delete('/stories/:id', (req, res) => {
447
468
  if (!ok) return res.status(404).json({ error: 'Story not found' });
448
469
  addTombstone('story', id, { source: 'api', fsPaths: [dir] });
449
470
  let dirRemoved = false;
450
- try { fs.rmSync(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
471
+ try { await fsp.rm(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
451
472
  res.json({ deleted: true, dirRemoved });
452
473
  } catch (err) {
453
474
  res.status(500).json({ error: err.message });
@@ -459,7 +480,7 @@ router.delete('/stories/:id', (req, res) => {
459
480
  *
460
481
  * Tombstones + removes `~/.karajan/sessions/<id>/` (KJC-TSK-0380).
461
482
  */
462
- router.delete('/sessions/:id', (req, res) => {
483
+ router.delete('/sessions/:id', async (req, res) => {
463
484
  try {
464
485
  const id = req.params.id;
465
486
  const dir = path.join(getKjHome(), 'sessions', id);
@@ -467,7 +488,7 @@ router.delete('/sessions/:id', (req, res) => {
467
488
  if (!ok) return res.status(404).json({ error: 'Session not found' });
468
489
  addTombstone('session', id, { source: 'api', fsPaths: [dir] });
469
490
  let dirRemoved = false;
470
- try { fs.rmSync(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
491
+ try { await fsp.rm(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
471
492
  res.json({ deleted: true, dirRemoved });
472
493
  } catch (err) {
473
494
  res.status(500).json({ error: err.message });
@@ -482,7 +503,7 @@ router.delete('/sessions/:id', (req, res) => {
482
503
  * delete the project: a project can have multiple plans, deleting one
483
504
  * plan only removes that plan's HUs from the board.
484
505
  */
485
- router.delete('/plans/:planId', (req, res) => {
506
+ router.delete('/plans/:planId', async (req, res) => {
486
507
  try {
487
508
  const planId = req.params.planId;
488
509
  // Locate the plan file by scanning both canonical and legacy plans
@@ -492,15 +513,15 @@ router.delete('/plans/:planId', (req, res) => {
492
513
  let foundPath = null;
493
514
  try {
494
515
  outer: for (const plansRoot of getHuBoardPlansDirs()) {
495
- if (!fs.existsSync(plansRoot)) continue;
496
- for (const projSlug of fs.readdirSync(plansRoot)) {
516
+ if (!(await pathExists(plansRoot))) continue;
517
+ for (const projSlug of await fsp.readdir(plansRoot)) {
497
518
  const candidate = path.join(plansRoot, projSlug, `plan-${planId}.json`);
498
- if (fs.existsSync(candidate)) { foundPath = candidate; break outer; }
519
+ if (await pathExists(candidate)) { foundPath = candidate; break outer; }
499
520
  }
500
521
  }
501
522
  } catch { /* plansRoot missing */ }
502
523
  addTombstone('plan', planId, { source: 'api', fsPaths: foundPath ? [foundPath] : [] });
503
- if (foundPath) { try { fs.unlinkSync(foundPath); } catch { /* */ } }
524
+ if (foundPath) { try { await fsp.unlink(foundPath); } catch { /* */ } }
504
525
  res.json({ deleted: true, fileRemoved: !!foundPath });
505
526
  } catch (err) {
506
527
  res.status(500).json({ error: err.message });
@@ -770,11 +791,11 @@ function promptsDir() {
770
791
  // Solomon escalation; anything older is a crashed runner.
771
792
  const PROMPT_ZOMBIE_TTL_MS = 30 * 60 * 1000;
772
793
 
773
- router.get('/prompts', (_req, res) => {
794
+ router.get('/prompts', async (_req, res) => {
774
795
  try {
775
796
  const dir = promptsDir();
776
- if (!fs.existsSync(dir)) return res.json([]);
777
- const entries = fs.readdirSync(dir);
797
+ if (!(await pathExists(dir))) return res.json([]);
798
+ const entries = await fsp.readdir(dir);
778
799
  const result = [];
779
800
  const now = Date.now();
780
801
  for (const name of entries) {
@@ -784,21 +805,21 @@ router.get('/prompts', (_req, res) => {
784
805
  const answerPath = path.join(dir, `${promptId}.answer.json`);
785
806
  // KJC-TSK-0380 — skip tombstoned prompts and clean their files.
786
807
  if (isTombstonedFn('prompt', promptId)) {
787
- try { fs.unlinkSync(mainPath); } catch { /* */ }
788
- try { fs.unlinkSync(answerPath); } catch { /* */ }
808
+ try { await fsp.unlink(mainPath); } catch { /* */ }
809
+ try { await fsp.unlink(answerPath); } catch { /* */ }
789
810
  continue;
790
811
  }
791
812
  let parsed;
792
813
  try {
793
- parsed = JSON.parse(fs.readFileSync(mainPath, 'utf-8'));
814
+ parsed = JSON.parse(await fsp.readFile(mainPath, 'utf-8'));
794
815
  } catch { continue; /* malformed — skip */ }
795
816
  // KJC-BUG-0038: zombie detection. Prefer parsed.createdAt; fall back
796
817
  // to mtime if the runner skipped the timestamp (older formats).
797
818
  const createdMs = Date.parse(parsed?.createdAt || '');
798
- const ageRef = Number.isFinite(createdMs) ? createdMs : safeMtime(mainPath);
819
+ const ageRef = Number.isFinite(createdMs) ? createdMs : await safeMtime(mainPath);
799
820
  if (ageRef && (now - ageRef) > PROMPT_ZOMBIE_TTL_MS) {
800
- try { fs.unlinkSync(mainPath); } catch { /* */ }
801
- try { fs.unlinkSync(answerPath); } catch { /* */ }
821
+ try { await fsp.unlink(mainPath); } catch { /* */ }
822
+ try { await fsp.unlink(answerPath); } catch { /* */ }
802
823
  addTombstone('prompt', promptId, { source: 'zombie-ttl', fsPaths: [mainPath, answerPath] });
803
824
  continue;
804
825
  }
@@ -811,8 +832,8 @@ router.get('/prompts', (_req, res) => {
811
832
  }
812
833
  });
813
834
 
814
- function safeMtime(p) {
815
- try { return fs.statSync(p).mtimeMs; } catch { return 0; }
835
+ async function safeMtime(p) {
836
+ try { return (await fsp.stat(p)).mtimeMs; } catch { return 0; }
816
837
  }
817
838
 
818
839
  /**
@@ -821,20 +842,20 @@ function safeMtime(p) {
821
842
  * it, deletes both files, and resolves askQuestion. An answer of
822
843
  * "stop" tells the runner to abort the session.
823
844
  */
824
- router.post('/prompts/:promptId/answer', (req, res) => {
845
+ router.post('/prompts/:promptId/answer', async (req, res) => {
825
846
  try {
826
847
  const { answer } = req.body || {};
827
848
  if (typeof answer !== 'string') {
828
849
  return res.status(400).json({ error: 'Body must contain an "answer" string' });
829
850
  }
830
851
  const dir = promptsDir();
831
- fs.mkdirSync(dir, { recursive: true });
852
+ await fsp.mkdir(dir, { recursive: true });
832
853
  const promptPath = path.join(dir, `${req.params.promptId}.json`);
833
- if (!fs.existsSync(promptPath)) {
854
+ if (!(await pathExists(promptPath))) {
834
855
  return res.status(404).json({ error: `Prompt not found: ${req.params.promptId}` });
835
856
  }
836
857
  const answerPath = path.join(dir, `${req.params.promptId}.answer.json`);
837
- fs.writeFileSync(
858
+ await fsp.writeFile(
838
859
  answerPath,
839
860
  JSON.stringify({ answer, answeredAt: new Date().toISOString() }, null, 2),
840
861
  'utf-8'
@@ -912,14 +933,14 @@ router.get('/events', (req, res) => {
912
933
  * Path is the same one that `runPlan` wrote to:
913
934
  * ~/.karajan/hu-board-runs/<planId>.log (honours KJ_HOME in tests).
914
935
  */
915
- router.get('/plans/:planId/log', (req, res) => {
936
+ router.get('/plans/:planId/log', async (req, res) => {
916
937
  try {
917
938
  const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
918
939
  const logPath = path.join(runsDir, `${req.params.planId}.log`);
919
- if (!fs.existsSync(logPath)) {
940
+ if (!(await pathExists(logPath))) {
920
941
  return res.json({ exists: false, size: 0, content: '' });
921
942
  }
922
- const stat = fs.statSync(logPath);
943
+ const stat = await fsp.stat(logPath);
923
944
  const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
924
945
  // Cap per-request read to 1 MiB so a huge log can't blow up the
925
946
  // response. The client will keep polling and catch up.
@@ -927,13 +948,13 @@ router.get('/plans/:planId/log', (req, res) => {
927
948
  const length = Math.min(MAX_READ, stat.size - offset);
928
949
  let content = '';
929
950
  if (length > 0) {
930
- const fd = fs.openSync(logPath, 'r');
951
+ const fh = await fsp.open(logPath, 'r');
931
952
  try {
932
953
  const buf = Buffer.alloc(length);
933
- fs.readSync(fd, buf, 0, length, offset);
954
+ await fh.read(buf, 0, length, offset);
934
955
  content = buf.toString('utf-8');
935
956
  } finally {
936
- fs.closeSync(fd);
957
+ await fh.close();
937
958
  }
938
959
  }
939
960
  res.json({ exists: true, size: stat.size, content });
@@ -1230,26 +1251,26 @@ router.post('/runs/:planId/stop', async (req, res) => {
1230
1251
  }
1231
1252
  });
1232
1253
 
1233
- router.get('/runs/:commandId/log', (req, res) => {
1254
+ router.get('/runs/:commandId/log', async (req, res) => {
1234
1255
  try {
1235
1256
  const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
1236
1257
  const logPath = path.join(runsDir, `${req.params.commandId}.log`);
1237
- if (!fs.existsSync(logPath)) {
1258
+ if (!(await pathExists(logPath))) {
1238
1259
  return res.json({ exists: false, size: 0, content: '' });
1239
1260
  }
1240
- const stat = fs.statSync(logPath);
1261
+ const stat = await fsp.stat(logPath);
1241
1262
  const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
1242
1263
  const MAX_READ = 1024 * 1024;
1243
1264
  const length = Math.min(MAX_READ, stat.size - offset);
1244
1265
  let content = '';
1245
1266
  if (length > 0) {
1246
- const fd = fs.openSync(logPath, 'r');
1267
+ const fh = await fsp.open(logPath, 'r');
1247
1268
  try {
1248
1269
  const buf = Buffer.alloc(length);
1249
- fs.readSync(fd, buf, 0, length, offset);
1270
+ await fh.read(buf, 0, length, offset);
1250
1271
  content = buf.toString('utf-8');
1251
1272
  } finally {
1252
- fs.closeSync(fd);
1273
+ await fh.close();
1253
1274
  }
1254
1275
  }
1255
1276
  res.json({ exists: true, size: stat.size, content });
@@ -130,7 +130,7 @@ export function appendZombieCheckpoint(existingJson, reason, isoTimestamp) {
130
130
  * @returns {string}
131
131
  */
132
132
  import { getKjHome } from "./db.js";
133
- export function getSessionsDir() {
133
+ function getSessionsDir() {
134
134
  return path.join(getKjHome(), "sessions");
135
135
  }
136
136
 
@@ -12,7 +12,7 @@ import { resolveBin } from "./resolve-bin.js";
12
12
  * Returns `{tokens_in, tokens_out, cached_tokens}` or null when no usage
13
13
  * is found (= unmeasured; callers leave the fields undefined).
14
14
  */
15
- export function extractAiderUsage(text) {
15
+ function extractAiderUsage(text) {
16
16
  if (!text) return null;
17
17
  for (const line of text.split("\n")) {
18
18
  const t = line.trim();
@@ -12,7 +12,7 @@ import { resolveBin } from "./resolve-bin.js";
12
12
  * so the caller leaves `cached_tokens` undefined (= unmeasured,
13
13
  * distinct from a measured 0).
14
14
  */
15
- export function extractGeminiUsage(text) {
15
+ function extractGeminiUsage(text) {
16
16
  if (!text) return null;
17
17
  const candidates = [];
18
18
  for (const line of text.split("\n")) {
@@ -10,7 +10,7 @@ import { resolveBin } from "./resolve-bin.js";
10
10
  * nested `usage` object. Returns `{tokens_in, tokens_out, cached_tokens}`
11
11
  * or null (= unmeasured).
12
12
  */
13
- export function extractOpenCodeUsage(text) {
13
+ function extractOpenCodeUsage(text) {
14
14
  if (!text) return null;
15
15
  const candidates = [text.trim()];
16
16
  for (const line of text.split("\n")) {
@@ -16,6 +16,7 @@
16
16
 
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
+ import { escapeRegExp } from "../utils/escape-regexp.js";
19
20
 
20
21
  const PAGE_BYTE_LIMIT = 32 * 1024; // ≈ 8K tokens, the typical agent budget per fetch
21
22
 
@@ -65,7 +66,7 @@ const CHECKS = [
65
66
  if (!found) return { ok: false, hint: "Add robots.txt with `User-agent: GPTBot|ClaudeBot|PerplexityBot\\nAllow: /` blocks.", details: {} };
66
67
  const text = safeRead(path.join(rootDir, found));
67
68
  const aiBots = ["GPTBot", "ClaudeBot", "PerplexityBot", "anthropic-ai", "Google-Extended"];
68
- const allowed = aiBots.filter((bot) => new RegExp(`User-agent:\\s*${bot}[\\s\\S]*?Allow:\\s*/`, "i").test(text));
69
+ const allowed = aiBots.filter((bot) => new RegExp(`User-agent:\\s*${escapeRegExp(bot)}[\\s\\S]*?Allow:\\s*/`, "i").test(text));
69
70
  const ok = allowed.length > 0;
70
71
  return ok
71
72
  ? { ok: true, hint: null, details: { found, allowed } }
@@ -71,7 +71,8 @@ function formatInjectionBlock(inj) {
71
71
  if (items.length === 0) continue;
72
72
  lines.push(` - ${severity} (${items.length}):`);
73
73
  for (const f of items.slice(0, MAX_SAMPLE_INJECTION_PER_SEVERITY)) {
74
- lines.push(` - ${f.file || "(unknown)"}${f.line ? `:${f.line}` : ""} [${f.type}] ${f.pattern}`);
74
+ const lineSuffix = f.line ? `:${f.line}` : "";
75
+ lines.push(` - ${f.file || "(unknown)"}${lineSuffix} [${f.type}] ${f.pattern}`);
75
76
  }
76
77
  if (items.length > MAX_SAMPLE_INJECTION_PER_SEVERITY) lines.push(` - ... and ${items.length - MAX_SAMPLE_INJECTION_PER_SEVERITY} more in ${severity}`);
77
78
  }
@@ -85,7 +86,8 @@ function formatCircularDepsBlock(circularDeps) {
85
86
  return ["### Circular Dependencies (architecture)", `- Status: not available — ${circularDeps.reason || "madge not available"}`, ""];
86
87
  }
87
88
  const lines = ["### Circular Dependencies (architecture)"];
88
- lines.push(`- Total cycles: ${circularDeps.total ?? 0}${circularDeps.suppressedCount ? ` (+${circularDeps.suppressedCount} suppressed)` : ""}`);
89
+ const suppressedSuffix = circularDeps.suppressedCount ? ` (+${circularDeps.suppressedCount} suppressed)` : "";
90
+ lines.push(`- Total cycles: ${circularDeps.total ?? 0}${suppressedSuffix}`);
89
91
  if ((circularDeps.total ?? 0) > 0) {
90
92
  const groups = groupCyclesBySeverity(circularDeps.cycles || []);
91
93
  for (const [severity, cycles] of Object.entries(groups)) {
@@ -118,7 +120,8 @@ function formatSemgrepBlock(semgrepFindings) {
118
120
  if (findings.length === 0) continue;
119
121
  lines.push(` - ${severity} (${findings.length}):`);
120
122
  for (const f of findings.slice(0, MAX_SAMPLE_SEMGREP_PER_SEVERITY)) {
121
- const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "";
123
+ const lineSuffix = f.line ? `:${f.line}` : "";
124
+ const loc = f.file ? `${f.file}${lineSuffix}` : "";
122
125
  lines.push(` - ${loc} [${f.rule}]`);
123
126
  }
124
127
  if (findings.length > MAX_SAMPLE_SEMGREP_PER_SEVERITY) {
@@ -172,7 +175,9 @@ function formatDeadExportsBlock(deadExports) {
172
175
  if (items.length === 0) continue;
173
176
  lines.push(` - ${severity} (${items.length}):`);
174
177
  for (const item of items.slice(0, MAX_SAMPLE_KNIP_PER_SEVERITY)) {
175
- lines.push(` - ${item.path}${item.line ? `:${item.line}` : ""} [${item.rule}]${item.name ? ` \`${item.name}\`` : ""}`);
178
+ const lineSuffix = item.line ? `:${item.line}` : "";
179
+ const nameSuffix = item.name ? ` \`${item.name}\`` : "";
180
+ lines.push(` - ${item.path}${lineSuffix} [${item.rule}]${nameSuffix}`);
176
181
  }
177
182
  if (items.length > MAX_SAMPLE_KNIP_PER_SEVERITY) {
178
183
  lines.push(` - ... and ${items.length - MAX_SAMPLE_KNIP_PER_SEVERITY} more in ${severity}`);
@@ -246,7 +251,8 @@ function formatSonarBlock(sonarFindings) {
246
251
  if (issues.length === 0) continue;
247
252
  lines.push(` - ${severity} (${issues.length}):`);
248
253
  for (const issue of issues.slice(0, MAX_SAMPLE_SONAR_PER_SEVERITY)) {
249
- const loc = issue.component ? `${issue.component}${issue.line ? `:${issue.line}` : ""}` : "";
254
+ const lineSuffix = issue.line ? `:${issue.line}` : "";
255
+ const loc = issue.component ? `${issue.component}${lineSuffix}` : "";
250
256
  const rule = issue.rule ? ` [${issue.rule}]` : "";
251
257
  lines.push(` - ${loc}${rule} ${issue.message || ""}`.trim());
252
258
  }
@@ -16,6 +16,7 @@
16
16
  // metadata for auditability — no silent dropping.
17
17
 
18
18
  import fs from "node:fs";
19
+ import { escapeRegExp } from "../utils/escape-regexp.js";
19
20
 
20
21
  export const DEFAULT_FALSE_POSITIVES = [
21
22
  {
@@ -59,8 +60,8 @@ export function hasInlineIgnore(filePath, line, ruleId, tool = "sonar") {
59
60
  const lines = fs.readFileSync(filePath, "utf8").split("\n");
60
61
  const idx = line - 1;
61
62
  const candidates = [lines[idx], lines[idx - 1]].filter(Boolean);
62
- const escapedRule = escapeRegex(ruleId);
63
- const escapedTool = escapeRegex(tool);
63
+ const escapedRule = escapeRegExp(ruleId);
64
+ const escapedTool = escapeRegExp(tool);
64
65
  // New generalised marker + legacy sonar-only marker.
65
66
  const reAudit = new RegExp(`karajan-audit-ignore\\s*:\\s*${escapedTool}:${escapedRule}\\b`);
66
67
  const reLegacy = tool === "sonar"
@@ -72,10 +73,6 @@ export function hasInlineIgnore(filePath, line, ruleId, tool = "sonar") {
72
73
  }
73
74
  }
74
75
 
75
- function escapeRegex(s) {
76
- return String(s).replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
77
- }
78
-
79
76
  /**
80
77
  * Filter a list of normalised audit findings into kept + suppressed.
81
78
  *
@@ -7,6 +7,8 @@
7
7
  * See docs/plan-adherence.md (PR-C) for the full spec.
8
8
  */
9
9
 
10
+ import { escapeRegExp } from "../utils/escape-regexp.js";
11
+
10
12
  const WEIGHTS = {
11
13
  commit_attribution: 40,
12
14
  acceptance_tests: 30,
@@ -20,7 +22,7 @@ const WEIGHTS = {
20
22
  * appear inside HU ids so we anchor on whole-token rather than \b.
21
23
  */
22
24
  function huIdMatcher(huId) {
23
- const escaped = String(huId).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
25
+ const escaped = escapeRegExp(huId);
24
26
  return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, "i");
25
27
  }
26
28
 
@@ -72,6 +72,10 @@ export async function collectSemgrepFindings(projectDir, logger = null) {
72
72
  return { available: true, ...parseSemgrepOutput(parsed, projectDir) };
73
73
  }
74
74
 
75
+ function relativizeSemgrepPath(p, projectDir) {
76
+ return p.startsWith(projectDir + "/") ? p.slice(projectDir.length + 1) : p;
77
+ }
78
+
75
79
  /**
76
80
  * Convert semgrep's JSON shape into the flat findings list the prompt
77
81
  * builder consumes. Semgrep emits results[] with rule id, severity,
@@ -86,7 +90,7 @@ export function parseSemgrepOutput(raw, projectDir = "") {
86
90
  const findings = [];
87
91
  for (const r of results) {
88
92
  const file = r.path && projectDir
89
- ? r.path.startsWith(projectDir + "/") ? r.path.slice(projectDir.length + 1) : r.path
93
+ ? relativizeSemgrepPath(r.path, projectDir)
90
94
  : r.path || "";
91
95
  findings.push({
92
96
  rule: r.check_id || "(unknown)",
@@ -37,7 +37,14 @@ const SILENCED_PATTERNS = /killed\s+after\s+\d+\s*ms|silence\s*timeout|no\s+outp
37
37
  // ("You've hit your session limit · resets 10:10pm"). Without them the
38
38
  // message fell through to UNKNOWN_FATAL and the run aborted instead of
39
39
  // hibernating until the reset.
40
- const RATE_LIMIT_PATTERNS = /usage\s+limit|rate\s*limit|too\s+many\s+requests|\b429\b|throttl|exceeded\s+your\s+current\s+quota|resource\s+exhausted|quota\s+exceeded|token\s+limit\s+reached|monthly\s+limit|weekly\s+limit|daily\s+limit|session\s+limit/i;
40
+ // Decomposed by family (S5843, complexity 36 → 3 named groups). The
41
+ // union of tokens is identical to the original single alternation.
42
+ const RATE_LIMIT_GROUPS = [
43
+ /rate\s*limit|too\s+many\s+requests|\b429\b|throttl/i,
44
+ /exceeded\s+your\s+current\s+quota|resource\s+exhausted|quota\s+exceeded|token\s+limit\s+reached/i,
45
+ /usage\s+limit|monthly\s+limit|weekly\s+limit|daily\s+limit|session\s+limit/i,
46
+ ];
47
+ const RATE_LIMIT_PATTERNS = { test: (text) => RATE_LIMIT_GROUPS.some((re) => re.test(text)) };
41
48
  const API_DOWN_PATTERNS = /\b50[0-4]\b|bad\s+gateway|service\s+unavailable|gateway\s+timeout|overloaded|internal\s+server\s+error/i;
42
49
  const NETWORK_PATTERNS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket\s+hang\s+up|fetch\s+failed|network\s+error/i;
43
50
 
@@ -179,8 +179,8 @@ export function buildDecision({ triage, task: _task, config = {}, overrides = {}
179
179
  solomonReason = "triage returned no level";
180
180
  }
181
181
 
182
- const rolesOn = Array.from(roles).sort();
183
- const rolesOff = Array.from(VALID_ROLES).filter((r) => !roles.has(r)).sort();
182
+ const rolesOn = Array.from(roles).sort((a, b) => a.localeCompare(b));
183
+ const rolesOff = Array.from(VALID_ROLES).filter((r) => !roles.has(r)).sort((a, b) => a.localeCompare(b));
184
184
 
185
185
  const rationale = buildRationale({ level, taskType, rolesOn, appliedOverrides, triage });
186
186
 
@@ -161,8 +161,8 @@ export function applyRulingToDecision(decision, ruling, logger) {
161
161
 
162
162
  return {
163
163
  ...decision,
164
- rolesOn: Array.from(rolesOn).sort(),
165
- rolesOff: Array.from(rolesOff).sort(),
164
+ rolesOn: Array.from(rolesOn).sort((a, b) => a.localeCompare(b)),
165
+ rolesOff: Array.from(rolesOff).sort((a, b) => a.localeCompare(b)),
166
166
  appliedOverrides: [...decision.appliedOverrides, ...applied],
167
167
  confidence: applied.length > 0 ? "high" : decision.confidence,
168
168
  consultSolomon: false,
@@ -5,6 +5,4 @@ export {
5
5
  scheduleResume,
6
6
  cancelScheduled,
7
7
  reconcileAll,
8
- acquireStandbyLock,
9
- markStandbyDone,
10
8
  } from "@karajan/core/standby-scheduler";
@@ -38,7 +38,8 @@ function promptContinueWithLlm({ promptFn = null } = {}) {
38
38
  function formatFindings(findings) {
39
39
  const lines = [];
40
40
  for (const f of findings) {
41
- const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "";
41
+ const lineSuffix = f.line ? `:${f.line}` : "";
42
+ const loc = f.file ? `${f.file}${lineSuffix}` : "";
42
43
  const rule = f.rule ? ` [${f.rule}]` : "";
43
44
  lines.push(` - [${f.severity.toUpperCase()}] ${loc}${rule}`);
44
45
  lines.push(` ${f.description}`);
@@ -116,7 +117,8 @@ function formatAudit(parsed, usage = null) {
116
117
  function formatUsageSummary(usage) {
117
118
  if (!usage) return null;
118
119
  const lines = ["## LLM Usage"];
119
- lines.push(`- Provider: ${usage.provider}${usage.model ? ` (${usage.model})` : ""}`);
120
+ const modelSuffix = usage.model ? ` (${usage.model})` : "";
121
+ lines.push(`- Provider: ${usage.provider}${modelSuffix}`);
120
122
  if (typeof usage.durationMs === "number") {
121
123
  lines.push(`- Duration: ${(usage.durationMs / 1000).toFixed(1)}s`);
122
124
  }
@@ -130,6 +132,9 @@ function formatUsageSummary(usage) {
130
132
  // locale-less Node and "1,234" on a US-locale workstation).
131
133
  const fmt = (n) => n.toLocaleString("en-US");
132
134
  lines.push(`- Tokens: ${fmt(usage.total_tokens)} total (${fmt(usage.tokens_in)} in, ${fmt(usage.tokens_out)} out)`);
135
+ if (typeof usage.cached_tokens === "number" && usage.cached_tokens > 0) {
136
+ lines.push(`- Cached tokens: ${fmt(usage.cached_tokens)} (prompt-cache hits)`);
137
+ }
133
138
  if (typeof usage.cost_usd === "number") {
134
139
  lines.push(`- Estimated cost: $${usage.cost_usd.toFixed(4)} USD`);
135
140
  }
@@ -402,4 +407,4 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
402
407
  }
403
408
 
404
409
  // Exposed for tests — kept module-private otherwise.
405
- export { formatAudit, formatUsageSummary, AUDIT_DIMENSIONS };
410
+ export { formatAudit, formatUsageSummary };
@@ -128,7 +128,7 @@ export function waitForEarlyExit(child, logPath) {
128
128
  // out, so we never announce "HU Board started" for an unreachable
129
129
  // server. Overridable with KJ_BOARD_READY_TIMEOUT_MS (tests set it
130
130
  // low to stay fast).
131
- export async function waitForBoardReachable(port, totalTimeoutMs) {
131
+ async function waitForBoardReachable(port, totalTimeoutMs) {
132
132
  const envTimeout = Number(process.env.KJ_BOARD_READY_TIMEOUT_MS);
133
133
  const budget = Number.isFinite(envTimeout) && envTimeout >= 0
134
134
  ? envTimeout
@@ -49,7 +49,10 @@ export async function standbyListCommand({ json } = {}) {
49
49
  const ms = new Date(s.cooldownUntil).getTime() - now;
50
50
  const status = ms <= 0 ? "✓ lista para resume" : `⏳ resume en ${formatCountdown(ms)}`;
51
51
  console.log(` ${s.sessionId} · ${s.reason || "?"} · ${status}`);
52
- if (s.planId) console.log(` plan: ${s.planId}${s.huId ? `, HU: ${s.huId}` : ""}`);
52
+ if (s.planId) {
53
+ const huSuffix = s.huId ? `, HU: ${s.huId}` : "";
54
+ console.log(` plan: ${s.planId}${huSuffix}`);
55
+ }
53
56
  }
54
57
  }
55
58
 
@@ -187,8 +187,8 @@ function stripRuntimeOnlyKeys(config) {
187
187
  // warning on every subsequent kj run.
188
188
  if (out.sonarqube && typeof out.sonarqube === "object"
189
189
  && Object.prototype.hasOwnProperty.call(out.sonarqube, "enabled")) {
190
- const { enabled: _drop, ...rest } = out.sonarqube;
191
- void _drop;
190
+ const rest = { ...out.sonarqube };
191
+ delete rest.enabled;
192
192
  out.sonarqube = rest;
193
193
  }
194
194
  return out;