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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/hu-board/src/routes/api.js +70 -49
- package/packages/hu-board/src/zombie-reaper.js +1 -1
- package/src/agents/aider-agent.js +1 -1
- package/src/agents/gemini-agent.js +1 -1
- package/src/agents/opencode-agent.js +1 -1
- package/src/audit/agent-readiness.js +2 -1
- package/src/audit/deterministic-summary.js +11 -5
- package/src/audit/issue-filter.js +3 -6
- package/src/audit/plan-adherence.js +3 -1
- package/src/audit/semgrep-findings.js +5 -1
- package/src/brain/agent-error-classifier.js +8 -1
- package/src/brain/decisor.js +2 -2
- package/src/brain/solomon-consult.js +2 -2
- package/src/brain/standby-scheduler.js +0 -2
- package/src/commands/audit.js +8 -3
- package/src/commands/board.js +1 -1
- package/src/commands/standby.js +4 -1
- package/src/config/loader.js +2 -2
- package/src/golden/asserter.js +3 -1
- package/src/golden/schema.js +1 -1
- package/src/hu/auto-generator.js +1 -1
- package/src/lang/tree-sitter-loader.js +1 -5
- package/src/mcp/handlers/management-handlers.js +4 -3
- package/src/orchestrator/drivers/post-loop.js +43 -91
- package/src/orchestrator/flow-runner.js +25 -0
- package/src/orchestrator/fs-leak-detector.js +6 -1
- package/src/orchestrator/preflight-checks.js +51 -0
- package/src/orchestrator/solomon-rules.js +12 -1
- package/src/prompts/audit.js +12 -11
- package/src/prompts/hu-reviewer.js +1 -1
- package/src/prompts/planner.js +1 -1
- package/src/rag/embedders/factory.js +0 -1
- package/src/rag/retriever.js +1 -1
- package/src/roles/agent-role.js +1 -1
- package/src/roles/audit-role.js +10 -2
- package/src/session/journal/write-all.js +89 -0
- package/src/sonar/discovery.js +2 -1
- package/src/sonar/scanner.js +1 -1
- package/src/utils/cli-progress.js +4 -2
- package/src/utils/escape-regexp.js +15 -0
- package/src/utils/paths.js +0 -1
- package/src/utils/port-occupant.js +2 -1
- package/src/utils/project-detect.js +1 -1
- package/node_modules/better-sqlite3/build/Release/better_sqlite3.node +0 -0
- package/node_modules/deep-extend/CHANGELOG.md +0 -46
- package/node_modules/sqlite-vec/index.cjs +0 -46
- package/node_modules/sqlite-vec/index.d.ts +0 -17
- package/node_modules/sqlite-vec/index.mjs +0 -47
- package/node_modules/sqlite-vec-linux-x64/vec0.so +0 -0
- 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.
|
|
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,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
|
-
|
|
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 (!
|
|
211
|
-
for (const f of
|
|
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 =
|
|
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 (!
|
|
243
|
-
for (const f of
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 (!
|
|
496
|
-
for (const projSlug of
|
|
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 (
|
|
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 {
|
|
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 (!
|
|
777
|
-
const entries =
|
|
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 {
|
|
788
|
-
try {
|
|
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(
|
|
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 {
|
|
801
|
-
try {
|
|
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
|
|
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
|
-
|
|
852
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
832
853
|
const promptPath = path.join(dir, `${req.params.promptId}.json`);
|
|
833
|
-
if (!
|
|
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
|
-
|
|
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 (!
|
|
940
|
+
if (!(await pathExists(logPath))) {
|
|
920
941
|
return res.json({ exists: false, size: 0, content: '' });
|
|
921
942
|
}
|
|
922
|
-
const stat =
|
|
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
|
|
951
|
+
const fh = await fsp.open(logPath, 'r');
|
|
931
952
|
try {
|
|
932
953
|
const buf = Buffer.alloc(length);
|
|
933
|
-
|
|
954
|
+
await fh.read(buf, 0, length, offset);
|
|
934
955
|
content = buf.toString('utf-8');
|
|
935
956
|
} finally {
|
|
936
|
-
|
|
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 (!
|
|
1258
|
+
if (!(await pathExists(logPath))) {
|
|
1238
1259
|
return res.json({ exists: false, size: 0, content: '' });
|
|
1239
1260
|
}
|
|
1240
|
-
const stat =
|
|
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
|
|
1267
|
+
const fh = await fsp.open(logPath, 'r');
|
|
1247
1268
|
try {
|
|
1248
1269
|
const buf = Buffer.alloc(length);
|
|
1249
|
-
|
|
1270
|
+
await fh.read(buf, 0, length, offset);
|
|
1250
1271
|
content = buf.toString('utf-8');
|
|
1251
1272
|
} finally {
|
|
1252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
63
|
-
const escapedTool =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/brain/decisor.js
CHANGED
|
@@ -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,
|
package/src/commands/audit.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
410
|
+
export { formatAudit, formatUsageSummary };
|
package/src/commands/board.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/commands/standby.js
CHANGED
|
@@ -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)
|
|
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
|
|
package/src/config/loader.js
CHANGED
|
@@ -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
|
|
191
|
-
|
|
190
|
+
const rest = { ...out.sonarqube };
|
|
191
|
+
delete rest.enabled;
|
|
192
192
|
out.sonarqube = rest;
|
|
193
193
|
}
|
|
194
194
|
return out;
|