karajan-code 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +14 -6
  2. package/docs/README.es.md +1 -1
  3. package/package.json +8 -2
  4. package/packages/hu-board/package.json +3 -1
  5. package/packages/hu-board/public/app.js +81 -4025
  6. package/packages/hu-board/public/index.html +21 -0
  7. package/packages/hu-board/public/styles.css +32 -0
  8. package/packages/hu-board/public/utils/api.js +139 -0
  9. package/packages/hu-board/public/utils/board-view.js +462 -0
  10. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  11. package/packages/hu-board/public/utils/config-editor.js +181 -0
  12. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  13. package/packages/hu-board/public/utils/formatters.js +273 -0
  14. package/packages/hu-board/public/utils/graph-view.js +216 -0
  15. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  16. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  17. package/packages/hu-board/public/utils/log-panel.js +222 -0
  18. package/packages/hu-board/public/utils/modals.js +290 -0
  19. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  20. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  21. package/packages/hu-board/public/utils/project-actions.js +143 -0
  22. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  23. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  24. package/packages/hu-board/public/utils/server-push.js +252 -0
  25. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  26. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  27. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  28. package/packages/hu-board/public/wiki.html +32 -0
  29. package/packages/hu-board/public/wiki.js +53 -0
  30. package/packages/hu-board/src/config-yaml.js +14 -0
  31. package/packages/hu-board/src/db.js +93 -2
  32. package/packages/hu-board/src/format.js +79 -0
  33. package/packages/hu-board/src/plan-mutations.js +4 -4
  34. package/packages/hu-board/src/routes/api.js +43 -10
  35. package/packages/hu-board/src/routes/rag.js +1 -1
  36. package/packages/hu-board/src/routes/wiki.js +43 -0
  37. package/packages/hu-board/src/run-tracker.js +1 -1
  38. package/packages/hu-board/src/server.js +4 -2
  39. package/packages/hu-board/src/sync.js +1 -1
  40. package/scripts/audit-test-diet.mjs +151 -0
  41. package/scripts/git-hooks/post-merge +13 -2
  42. package/src/audit/ai-slop-findings.js +102 -0
  43. package/src/audit/deterministic-summary.js +18 -0
  44. package/src/audit/harness-scorecard.js +52 -3
  45. package/src/audit/harness-section.js +6 -2
  46. package/src/brain/standby-scheduler.js +10 -92
  47. package/src/brain/standby-store.js +12 -168
  48. package/src/checks/ai-trash.js +34 -0
  49. package/src/checks/qmd.js +41 -0
  50. package/src/checks/runner.js +10 -0
  51. package/src/checks/squeezr.js +36 -0
  52. package/src/cli/register-meta.js +36 -2
  53. package/src/cli/register-pipeline.js +3 -0
  54. package/src/cli/register-plan.js +10 -1
  55. package/src/commands/audit.js +6 -4
  56. package/src/commands/board.js +34 -0
  57. package/src/commands/clean.js +62 -0
  58. package/src/commands/doctor.js +6 -0
  59. package/src/commands/init.js +73 -8
  60. package/src/commands/plan/generate.js +9 -3
  61. package/src/commands/qmd.js +86 -0
  62. package/src/commands/resume.js +6 -45
  63. package/src/commands/run.js +6 -50
  64. package/src/commands/sync.js +30 -2
  65. package/src/config/defaults.js +5 -0
  66. package/src/config/schema.js +2 -0
  67. package/src/git/hu-snapshot.js +10 -105
  68. package/src/orchestrator/config-init.js +1 -0
  69. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  70. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
  71. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  72. package/src/orchestrator/hu-sub-pipeline.js +52 -8
  73. package/src/orchestrator/stages/coder-stage.js +8 -2
  74. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  75. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  76. package/src/plan/plan-hu-ops.js +16 -338
  77. package/src/plan/plan-id.js +4 -45
  78. package/src/plan/plan-validation.js +4 -82
  79. package/src/prompts/tool-correctness-judge.js +31 -0
  80. package/src/rag/vec-store.js +17 -253
  81. package/src/review/git-stash-helper.js +104 -0
  82. package/src/review/tdd-discipline.js +90 -0
  83. package/src/roles/audit-role.js +11 -1
  84. package/src/roles/tool-correctness-judge-role.js +78 -0
  85. package/src/session/mutators.js +4 -0
  86. package/src/sync/apply-canvas-patch.js +55 -0
  87. package/src/utils/ai-trash.js +48 -0
  88. package/src/utils/atomic-write.js +6 -43
  89. package/src/utils/cli-ask-question.js +76 -0
  90. package/src/utils/doc-links.js +2 -0
  91. package/src/utils/os-detect.js +10 -0
  92. package/src/utils/paths.js +14 -111
  93. package/src/utils/port-check.js +6 -45
  94. package/src/utils/process.js +4 -188
  95. package/src/utils/qmd-collection.js +72 -0
  96. package/src/utils/qmd-detect.js +18 -0
  97. package/src/utils/qmd-install.js +37 -0
  98. package/src/utils/repo-cleaner.js +79 -0
  99. package/src/utils/run-registry.js +9 -116
  100. package/src/utils/shared-paths.js +12 -41
  101. package/src/utils/squeezr-detect.js +18 -0
  102. package/src/utils/squeezr-install.js +37 -0
  103. package/src/utils/tool-call-extractor.js +56 -0
  104. package/src/utils/vector-store-orphans.js +89 -0
@@ -22,6 +22,7 @@ import {
22
22
  listPlanIdsForProject,
23
23
  updateStoryStatus,
24
24
  setProjectIsTest,
25
+ getProjectCost,
25
26
  } from '../db.js';
26
27
  import { fullScan } from '../sync.js';
27
28
  import { setHuStatus, setHuFields, markPlanReady, runPlan, renameProject, revertHuFromSnapshot } from '../plan-mutations.js';
@@ -285,6 +286,20 @@ router.get('/stories/:id', (req, res) => {
285
286
  }
286
287
  });
287
288
 
289
+ /**
290
+ * GET /api/projects/:id/cost - Aggregated $ cost for a project (KJC-TSK-0516).
291
+ * Sums stories.cost_usd, groups by plan_id, returns 404 if project missing.
292
+ */
293
+ router.get('/projects/:id/cost', (req, res) => {
294
+ try {
295
+ const cost = getProjectCost(req.params.id);
296
+ if (!cost) return res.status(404).json({ error: 'Project not found' });
297
+ res.json(cost);
298
+ } catch (err) {
299
+ res.status(500).json({ error: err.message });
300
+ }
301
+ });
302
+
288
303
  /**
289
304
  * GET /api/projects/:id/sessions - Sessions for a project.
290
305
  */
@@ -545,15 +560,27 @@ router.post('/tombstones/:type/:id/restore', (req, res) => {
545
560
  * rewritten before we ack, then re-synced into SQLite, so a reload
546
561
  * renders the committed state.
547
562
  */
548
- // Status que el USUARIO puede setear manualmente vía el dropdown del
549
- // modal. NO incluimos coding/reviewing/running — esos son lifecycle
550
- // del orquestador (settearlos a mano genera zombies). Tampoco
551
- // incluimos los canonicales (`running`) por la misma razón. `failed`
552
- // y `blocked` son útiles para que el usuario marque manualmente HUs
553
- // que no quiere correr ahora.
554
- // KJC-TSK-0403: 'failed' eliminado — el orquestador estampa result=fail
555
- // dejando status=pending. Setearlo a mano vía PATCH ya no tiene sentido.
556
- const ALLOWED_STORY_STATUSES = new Set(['pending', 'certified', 'done', 'blocked', 'needs_context']);
563
+ // KJC-TSK-0394 PR6 (AC 6): el PATCH endpoint sólo acepta el modelo
564
+ // canónico {pending, running, done}. Status legacy (`certified`,
565
+ // `coding`, `reviewing`, `failed`, `blocked`, `needs_context`) siguen
566
+ // vivos en plans existentes y los traduce la UI con canonicalStatus(),
567
+ // pero NO pueden ser seteados por escrituras nuevas — devolvemos 400
568
+ // con `suggestion` apuntando al mapping correcto. `running` queda fuera
569
+ // del dropdown del modal (lifecycle del orquestador), pero el endpoint
570
+ // lo acepta por si un programa externo lo necesita.
571
+ const ALLOWED_STORY_STATUSES = new Set(['pending', 'running', 'done']);
572
+ // Mapping canónico para la sugerencia del 400 cuando el caller manda
573
+ // un valor legacy. Mantenemos la regla viva en src/plan/plan-schema.js
574
+ // (mapLegacyStatusToResult) — esta tabla es sólo el subset accionable
575
+ // desde el endpoint (status + result).
576
+ const LEGACY_STATUS_SUGGESTION = {
577
+ certified: { status: 'done', result: 'pass' },
578
+ failed: { status: 'pending', result: 'fail' },
579
+ coding: { status: 'running', result: null },
580
+ reviewing: { status: 'running', result: null },
581
+ blocked: { status: 'pending', result: null },
582
+ needs_context: { status: 'pending', result: null },
583
+ };
557
584
  // KJC-TSK-0406: coder_model y reviewer_model son editables desde el
558
585
  // modal del board. Independientes — el usuario puede subir el reviewer
559
586
  // y bajar el coder sin afectar al resto del plan.
@@ -579,8 +606,14 @@ router.patch('/stories/:id', (req, res) => {
579
606
  });
580
607
  }
581
608
  if (hasStatus && !ALLOWED_STORY_STATUSES.has(body.status)) {
609
+ // KJC-TSK-0394 PR6 (AC 6): si el caller manda un status legacy,
610
+ // devolvemos un 400 con `suggestion` apuntando al mapping canónico.
611
+ // Una herramienta externa puede leer el body, aplicar el mapeo y
612
+ // re-intentar — sin tener que descubrir la tabla por ensayo y error.
613
+ const suggestion = LEGACY_STATUS_SUGGESTION[body.status] || null;
582
614
  return res.status(400).json({
583
615
  error: `status must be one of: ${[...ALLOWED_STORY_STATUSES].join(', ')}`,
616
+ suggestion,
584
617
  });
585
618
  }
586
619
 
@@ -1307,7 +1340,7 @@ router.post('/rag/query', async (req, res) => {
1307
1340
  return res.status(400).json({ error: "'text' is required (non-empty string)" });
1308
1341
  }
1309
1342
  try {
1310
- const { openVecStore, countChunks } = await import('../../../../src/rag/vec-store.js');
1343
+ const { openVecStore, countChunks } = await import('@karajan/core/vec-store');
1311
1344
  const { makeEmbedder } = await import('../../../../src/rag/embedders/factory.js');
1312
1345
  const { query } = await import('../../../../src/rag/retriever.js');
1313
1346
  const db = openVecStore({ dim: 768 });
@@ -5,7 +5,7 @@
5
5
  import { Router } from "express";
6
6
  import { existsSync, statSync } from "node:fs";
7
7
  import Database from "better-sqlite3";
8
- import { dbPath } from "../../../../src/rag/vec-store.js";
8
+ import { dbPath } from "@karajan/core/vec-store";
9
9
  import { readConfig } from "../config-yaml.js";
10
10
 
11
11
  const router = Router();
@@ -0,0 +1,43 @@
1
+ // KJC-TSK-0508 — HU Board Wiki panel route.
2
+ // POST /api/wiki/query → spawns `qmd query --format json` against the local
3
+ // per-project QMD wiki (registered by `kj init` via KJC-TSK-0506). Returns
4
+ // 503 with installable:true so the front renders an "Install QMD" CTA
5
+ // instead of a generic error when the binary is missing.
6
+ import { Router } from "express";
7
+ import { runCommand } from "@karajan/core/process";
8
+
9
+ const router = Router();
10
+ const QMD_NOT_INSTALLED = /command not found|ENOENT|not recognized as/i;
11
+
12
+ router.post("/query", async (req, res) => {
13
+ const raw = req.body?.query;
14
+ const query = typeof raw === "string" ? raw.trim() : "";
15
+ if (!query) return res.status(400).json({ ok: false, error: "query is required" });
16
+ const limit = Math.min(Math.max(parseInt(req.body?.limit, 10) || 10, 1), 50);
17
+ const collection = req.body?.collection ? String(req.body.collection).trim() : null;
18
+ const fast = req.body?.fast === true;
19
+
20
+ const args = ["query", query, "--format", "json", "-n", String(limit)];
21
+ if (collection) args.push("-c", collection);
22
+ if (fast) args.push("--no-rerank");
23
+
24
+ try {
25
+ const out = await runCommand("qmd", args, { timeout: 120_000 });
26
+ if (out.exitCode === 127 || QMD_NOT_INSTALLED.test(out.stderr || "")) {
27
+ return res.status(503).json({ ok: false, error: "qmd not installed", installable: true });
28
+ }
29
+ if (out.exitCode !== 0) {
30
+ const msg = (out.stderr || out.stdout || `qmd exit ${out.exitCode}`).trim();
31
+ return res.status(500).json({ ok: false, error: msg });
32
+ }
33
+ let results;
34
+ try { results = JSON.parse(out.stdout || "[]"); }
35
+ catch { return res.status(500).json({ ok: false, error: "qmd output is not valid JSON" }); }
36
+ res.json({ ok: true, results, count: Array.isArray(results) ? results.length : 0 });
37
+ } catch (err) {
38
+ if (err?.code === "ENOENT") return res.status(503).json({ ok: false, error: "qmd not installed", installable: true });
39
+ res.status(500).json({ ok: false, error: err?.message || String(err) });
40
+ }
41
+ });
42
+
43
+ export default router;
@@ -16,7 +16,7 @@
16
16
  * se borran como cleanup (responsabilidad de run-registry).
17
17
  */
18
18
 
19
- import { listActiveRuns as fsList } from "../../../src/utils/run-registry.js";
19
+ import { listActiveRuns as fsList } from "@karajan/core/run-registry";
20
20
 
21
21
  const runs = new Map();
22
22
  let reaperTimer = null;
@@ -10,13 +10,14 @@ import { fullScan, startWatcher } from './sync.js';
10
10
  import apiRoutes from './routes/api.js';
11
11
  import pipelineRoutes from './routes/pipeline.js';
12
12
  import ragRoutes from './routes/rag.js';
13
+ import wikiRoutes from './routes/wiki.js';
13
14
  import { authMiddleware } from './auth.js';
14
15
  import { getOrCreateToken, getTokenPath } from './token-store.js';
15
16
  import { reapZombieSessions } from './zombie-reaper.js';
16
17
  import { reapZombieHus } from './hu-zombie-reaper.js';
17
18
  import { setHuStatus as setHuStatusPlanMutation, setHuFailResult as setHuFailResultPlanMutation } from './plan-mutations.js';
18
19
  import { cleanupEphemeralProjects } from './ephemeral-cleaner.js';
19
- import { findAvailablePort as findAvailablePortBase } from '../../../src/utils/port-check.js';
20
+ import { findAvailablePort as findAvailablePortBase } from '@karajan/core/port-check';
20
21
 
21
22
  /**
22
23
  * Path to the PID file the CLI's `kj board start` / `kj board stop`
@@ -237,7 +238,7 @@ async function main() {
237
238
  // - cooldownUntil > now → re-programa setTimeout que dispara en cooldown
238
239
  // Idempotente (lockfile per session). Cero polling.
239
240
  try {
240
- const { reconcileAll } = await import('../../../src/brain/standby-scheduler.js');
241
+ const { reconcileAll } = await import('@karajan/core/standby-scheduler');
241
242
  const { spawn } = await import('node:child_process');
242
243
  const { join, dirname } = await import('node:path');
243
244
  const { fileURLToPath } = await import('node:url');
@@ -300,6 +301,7 @@ async function main() {
300
301
  app.use('/api', buildRateLimiter(), authMiddleware(), apiRoutes);
301
302
  app.use('/api/pipeline', authMiddleware(), pipelineRoutes);
302
303
  app.use('/api/rag', authMiddleware(), ragRoutes);
304
+ app.use('/api/wiki', authMiddleware(), wikiRoutes);
303
305
 
304
306
  // SPA fallback: serve index.html for non-API, non-static routes
305
307
  app.get('/{*splat}', (_req, res) => {
@@ -14,7 +14,7 @@ import {
14
14
  removeTombstone,
15
15
  } from './db.js';
16
16
  import { publish as publishEvent } from './event-bus.js';
17
- import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '../../../src/utils/shared-paths.js';
17
+ import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '@karajan/core/shared-paths';
18
18
  import { readConfig } from './config-yaml.js';
19
19
 
20
20
  /**
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/audit-test-diet.mjs — semantic loss-of-meaning auditor (KJC-TSK-0345).
4
+ * Categories: empty-no-expect, skipped-pending, imports-orphan, deprecated-export,
5
+ * subsumed-candidate. Each finding carries confidence (high|low). Complements
6
+ * tests/_diet/audit.mjs (which classifies by LOC bucket only).
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const HERE = dirname(fileURLToPath(import.meta.url));
13
+ const DEFAULT_ROOT = resolve(HERE, "..");
14
+ const SKIP_DIRS = new Set(["_diet", "fixtures", "node_modules"]);
15
+
16
+ export function walkTests(rootDir, testsSubdir = "tests") {
17
+ const out = [];
18
+ const base = join(rootDir, testsSubdir);
19
+ if (!existsSync(base)) return out;
20
+ const stack = [base];
21
+ while (stack.length) {
22
+ for (const e of readdirSync(stack.pop(), { withFileTypes: true })) {
23
+ const p = join(e.parentPath ?? base, e.name);
24
+ if (e.isDirectory() && !SKIP_DIRS.has(e.name)) stack.push(p);
25
+ else if (e.isFile() && /\.test\.(m?js|ts)$/.test(e.name)) out.push(p);
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+
31
+ export function parseImports(source) {
32
+ const imports = [];
33
+ const re = /^[ \t]*import\s+(?:([\w*\s{},]+?)\s+from\s+)?['"]([^'"]+)['"]/gm;
34
+ let m;
35
+ while ((m = re.exec(source)) !== null) {
36
+ const named = [];
37
+ const braced = (m[1] || "").match(/\{([^}]+)\}/);
38
+ if (braced) for (const part of braced[1].split(",")) {
39
+ const name = part.trim().split(/\s+as\s+/)[0].trim();
40
+ if (name) named.push(name);
41
+ }
42
+ imports.push({ specifier: m[2], named });
43
+ }
44
+ return imports;
45
+ }
46
+
47
+ export function resolveSrcImport(testFile, specifier, rootDir) {
48
+ if (!specifier.startsWith(".") && !specifier.startsWith("#")) return null;
49
+ const base = specifier.startsWith("#") ? resolve(rootDir, specifier.replace(/^#/, "src/")) : resolve(dirname(testFile), specifier);
50
+ for (const c of [base, `${base}.js`, `${base}.mjs`, join(base, "index.js")]) {
51
+ if (existsSync(c) && statSync(c).isFile()) return c;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export function extractItNames(source) {
57
+ const names = new Set();
58
+ for (const m of source.matchAll(/\b(?:it|test)(?:\.(?:only|skip|todo))?\s*\(\s*['"`]([^'"`]+)['"`]/g)) names.add(m[1]);
59
+ return names;
60
+ }
61
+
62
+ export function extractExports(source) {
63
+ const names = new Set();
64
+ for (const m of source.matchAll(/export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
65
+ for (const m of source.matchAll(/export\s*\{([^}]+)\}/g)) for (const part of m[1].split(",")) {
66
+ const name = part.trim().split(/\s+as\s+/).pop().trim();
67
+ if (name) names.add(name);
68
+ }
69
+ if (/export\s+default\b/.test(source)) names.add("default");
70
+ return names;
71
+ }
72
+
73
+ export function auditFile(testFile, rootDir) {
74
+ const source = readFileSync(testFile, "utf-8");
75
+ const rel = relative(rootDir, testFile);
76
+ const findings = [];
77
+ const hasIt = /\b(?:it|test)\s*\(/.test(source);
78
+ if (hasIt && !/\bexpect\s*\(/.test(source)) findings.push({ category: "empty-no-expect", confidence: "high", detail: "it()/test() blocks with no expect()" });
79
+ if (/\b(?:it|test|describe)\.skip\s*\(|\bxit\s*\(|\bxtest\s*\(|\b(?:it|test)\.todo\s*\(/.test(source)) {
80
+ findings.push({ category: "skipped-pending", confidence: "high", detail: "uses .skip / xit / xtest / .todo" });
81
+ }
82
+ const srcImports = [];
83
+ for (const imp of parseImports(source)) {
84
+ if (!imp.specifier.startsWith(".") && !imp.specifier.startsWith("#")) continue;
85
+ const resolved = resolveSrcImport(testFile, imp.specifier, rootDir);
86
+ if (!resolved) { findings.push({ category: "imports-orphan", confidence: "high", detail: `cannot resolve '${imp.specifier}'` }); continue; }
87
+ if (imp.named.length) {
88
+ const missing = imp.named.filter((n) => !extractExports(readFileSync(resolved, "utf-8")).has(n));
89
+ if (missing.length) findings.push({ category: "deprecated-export", confidence: "low", detail: `${imp.specifier} no longer exports: ${missing.join(", ")}` });
90
+ }
91
+ srcImports.push({ resolved: relative(rootDir, resolved), named: imp.named });
92
+ }
93
+ return { path: rel, srcImports, findings, itNames: extractItNames(source) };
94
+ }
95
+
96
+ export function findSubsumed(perFile) {
97
+ const bySrc = new Map();
98
+ for (const f of perFile) for (const imp of f.srcImports) {
99
+ if (!bySrc.has(imp.resolved)) bySrc.set(imp.resolved, []);
100
+ bySrc.get(imp.resolved).push({ path: f.path, named: new Set(imp.named), itNames: f.itNames });
101
+ }
102
+ const out = [];
103
+ for (const [src, consumers] of bySrc) {
104
+ if (consumers.length < 2) continue;
105
+ const sorted = [...consumers].sort((a, b) => a.named.size - b.named.size);
106
+ const narrow = sorted[0]; const broad = sorted[sorted.length - 1];
107
+ if (narrow.path === broad.path || narrow.named.size === 0 || broad.named.size === 0) continue;
108
+ if (!(narrow.named.size < broad.named.size && [...narrow.named].every((n) => broad.named.has(n)))) continue;
109
+ // Require ≥50% of narrow's it() names to also appear in broad's. Imports
110
+ // alone are a false-positive trap: two tests can share helpers and assert
111
+ // on disjoint behavior (e.g. tests/budget.test.js vs tests/utils/budget.test.js).
112
+ if (narrow.itNames.size === 0) continue;
113
+ const overlap = [...narrow.itNames].filter((n) => broad.itNames.has(n)).length;
114
+ if (overlap / narrow.itNames.size < 0.5) continue;
115
+ out.push({ category: "subsumed-candidate", confidence: "low", path: narrow.path, detail: `${overlap}/${narrow.itNames.size} it() names also in ${broad.path}; subset of imports from ${src}` });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ export function auditTestDiet(rootDir = DEFAULT_ROOT) {
121
+ const files = walkTests(rootDir);
122
+ const perFile = files.map((f) => auditFile(f, rootDir));
123
+ const findings = [];
124
+ for (const f of perFile) for (const x of f.findings) findings.push({ path: f.path, ...x });
125
+ for (const s of findSubsumed(perFile)) findings.push(s);
126
+ return { generatedAt: new Date().toISOString(), rootDir, totals: { filesAudited: files.length, findings: findings.length }, findings };
127
+ }
128
+
129
+ export function renderMarkdown(report) {
130
+ const byCat = new Map();
131
+ for (const f of report.findings) {
132
+ if (!byCat.has(f.category)) byCat.set(f.category, []);
133
+ byCat.get(f.category).push(f);
134
+ }
135
+ const lines = [`# Semantic test-diet audit`, ``, `Generated: ${report.generatedAt}`, `Files audited: ${report.totals.filesAudited}`, `Findings: ${report.totals.findings}`, ``];
136
+ for (const [cat, items] of byCat) {
137
+ lines.push(`## ${cat} (${items.length})`, ``);
138
+ for (const it of items) lines.push(`- \`${it.path}\` — ${it.detail} (confidence: ${it.confidence})`);
139
+ lines.push(``);
140
+ }
141
+ return lines.join("\n");
142
+ }
143
+
144
+ if (import.meta.url === `file://${process.argv[1]}`) {
145
+ const report = auditTestDiet(DEFAULT_ROOT);
146
+ const outDir = join(DEFAULT_ROOT, "audit");
147
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
148
+ writeFileSync(join(outDir, "test-diet-semantic.json"), JSON.stringify(report, null, 2));
149
+ writeFileSync(join(outDir, "test-diet-semantic.md"), renderMarkdown(report));
150
+ console.log(`audit: ${report.totals.filesAudited} files, ${report.totals.findings} findings -> audit/test-diet-semantic.{json,md}`);
151
+ }
@@ -3,5 +3,16 @@
3
3
  # never serves stale code. Installed by `kj rag install-hooks`. The marker
4
4
  # in this comment is how the installer tells kj-managed hooks from user hooks.
5
5
  set -euo pipefail
6
- if ! command -v kj >/dev/null 2>&1; then exit 0; fi
7
- kj rag index --since auto >/dev/null 2>&1 || true
6
+ if command -v kj >/dev/null 2>&1; then
7
+ kj rag index --since auto >/dev/null 2>&1 || true
8
+ fi
9
+
10
+ # KJC-TSK-0507 — Refresh the per-project QMD semantic wiki when the merge
11
+ # touched indexable folders (docs/, .reviews/, .karajan/plans/). Background
12
+ # so it never blocks the user's `git pull` / `git merge`.
13
+ if command -v qmd >/dev/null 2>&1; then
14
+ if git diff-tree -r --name-only ORIG_HEAD HEAD 2>/dev/null \
15
+ | grep -qE '^(docs/|\.reviews/|\.karajan/plans/)'; then
16
+ (qmd update >/dev/null 2>&1 || true) &
17
+ fi
18
+ fi
@@ -0,0 +1,102 @@
1
+ // AI-slop tells detector — KJC-TSK-0503. Deterministic, offline,
2
+ // regex-based scanner over JS/TS source. Five categories:
3
+ // tautologic-comments, banner-separators, boilerplate-docstrings,
4
+ // magic-fallbacks, redundant-jsdoc. Score 100 = clean.
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ const FILE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
10
+ const IGNORE_DIRS = new Set([
11
+ "node_modules", "dist", "build", "coverage",
12
+ ".git", ".karajan", "public", ".next", ".nuxt", ".vercel", ".cache",
13
+ ]);
14
+ const VERB = "(?:returns?|gets?|sets?|fetches?|loads?|saves?|creates?|builds?|makes?|computes?|handles?|checks?|validates?)";
15
+ const LINE_PATTERNS = {
16
+ "banner-separators": /\/[/*]\s*[=*\-_#~]{5,}/,
17
+ "magic-fallbacks": /(\?\?|\|\|)\s*(?:""|''|null|undefined)\s*(?:\)|;|,|$)/,
18
+ "redundant-jsdoc": new RegExp(`@param\\s+\\{[^}]+\\}\\s+(\\w+)\\s+(?:-\\s*)?(?:the\\s+)?\\1\\s+(?:parameter|argument|value)`, "i"),
19
+ };
20
+ const TAUT_RE = new RegExp(`^\\s*//\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*$`, "i");
21
+ const DOC_RE = new RegExp(`^\\s*/\\*\\*\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*\\*/\\s*$`, "i");
22
+ const DECL_RE = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)/;
23
+
24
+ export const CATEGORIES = Object.freeze([
25
+ "tautologic-comments", "banner-separators", "boilerplate-docstrings",
26
+ "magic-fallbacks", "redundant-jsdoc",
27
+ ]);
28
+
29
+ function nextDecl(lines, from) {
30
+ for (let j = from; j < Math.min(from + 3, lines.length); j++) {
31
+ const m = lines[j]?.match(DECL_RE);
32
+ if (m) return m[1];
33
+ }
34
+ return null;
35
+ }
36
+ const stem = (a, b) => { const al = a.toLowerCase(), bl = b.toLowerCase(); return al === bl || al.includes(bl) || bl.includes(al); };
37
+
38
+ export function scanText(relPath, text) {
39
+ const out = [];
40
+ const lines = text.split(/\r?\n/);
41
+ for (let i = 0; i < lines.length; i++) {
42
+ const line = lines[i];
43
+ const lineNo = i + 1;
44
+ for (const [category, re] of Object.entries(LINE_PATTERNS)) {
45
+ if (re.test(line)) out.push({ path: relPath, line: lineNo, category, snippet: line.trim().slice(0, 100) });
46
+ }
47
+ const t = line.match(TAUT_RE);
48
+ if (t) {
49
+ const decl = nextDecl(lines, i + 1);
50
+ if (decl && stem(decl, t[1])) out.push({ path: relPath, line: lineNo, category: "tautologic-comments", snippet: line.trim().slice(0, 100) });
51
+ }
52
+ const d = line.match(DOC_RE);
53
+ if (d) {
54
+ const decl = nextDecl(lines, i + 1);
55
+ if (decl && stem(decl, d[1])) out.push({ path: relPath, line: lineNo, category: "boilerplate-docstrings", snippet: line.trim().slice(0, 100) });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function listSourceFiles(root) {
62
+ const out = [];
63
+ const stack = [root];
64
+ while (stack.length) {
65
+ const dir = stack.pop();
66
+ let entries;
67
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
68
+ for (const e of entries) {
69
+ const full = path.join(dir, e.name);
70
+ if (e.isDirectory()) {
71
+ if (IGNORE_DIRS.has(e.name) || e.name.startsWith(".")) continue;
72
+ stack.push(full);
73
+ } else if (FILE_EXTS.has(path.extname(e.name))) out.push(full);
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ export async function collectAiSlop(rootDir, opts = {}) {
80
+ const maxLOC = Number.isFinite(opts.maxLOC) ? opts.maxLOC : 200_000;
81
+ const maxWorst = Number.isFinite(opts.maxWorst) ? opts.maxWorst : 10;
82
+ try {
83
+ const findings = [];
84
+ let totalLOC = 0, filesScanned = 0;
85
+ for (const file of listSourceFiles(rootDir)) {
86
+ if (totalLOC >= maxLOC) break;
87
+ let content;
88
+ try { content = fs.readFileSync(file, "utf-8"); } catch { continue; }
89
+ findings.push(...scanText(path.relative(rootDir, file) || file, content));
90
+ totalLOC += content.split(/\r?\n/).length;
91
+ filesScanned++;
92
+ }
93
+ const byCategory = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
94
+ for (const f of findings) byCategory[f.category]++;
95
+ const density = totalLOC > 0 ? (findings.length / totalLOC) * 100 : 0;
96
+ const score = Math.max(0, Math.min(100, Math.round(100 - density * 100)));
97
+ const worst = findings.slice().sort((a, b) => (a.path === b.path ? a.line - b.line : a.path.localeCompare(b.path))).slice(0, maxWorst);
98
+ return { available: true, filesScanned, totalLOC, total: findings.length, score, byCategory, worst };
99
+ } catch (err) {
100
+ return { available: false, reason: err?.message || String(err) };
101
+ }
102
+ }
@@ -41,11 +41,28 @@ export function formatDeterministicSummary(ctx) {
41
41
  if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
42
42
  if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
43
43
  if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
44
+ if (ctx.aiSlop) lines.push(...formatAiSlopBlock(ctx.aiSlop));
44
45
  if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
45
46
 
46
47
  return lines.join("\n");
47
48
  }
48
49
 
50
+ function formatAiSlopBlock(slop) {
51
+ if (!slop.available) return ["### AI-slop tells (deterministic)", `- Status: not available — ${slop.reason || "scan failed"}`, ""];
52
+ const lines = ["### AI-slop tells (deterministic)", `- Files scanned: ${slop.filesScanned ?? 0}`, `- Score: ${slop.score}/100 (100 = clean)`, `- Findings: ${slop.total ?? 0}`];
53
+ if ((slop.total ?? 0) > 0) {
54
+ for (const [cat, n] of Object.entries(slop.byCategory || {})) {
55
+ if (n > 0) lines.push(` - ${cat}: ${n}`);
56
+ }
57
+ if (Array.isArray(slop.worst) && slop.worst.length > 0) {
58
+ lines.push(" - Worst offenders:");
59
+ for (const f of slop.worst) lines.push(` - ${f.path}:${f.line} [${f.category}] ${f.snippet}`);
60
+ }
61
+ }
62
+ lines.push("");
63
+ return lines;
64
+ }
65
+
49
66
  function formatInjectionBlock(inj) {
50
67
  if (!inj.available) return ["### Prompt-injection scan", `- Status: not available — ${inj.reason || "scan failed"}`, ""];
51
68
  const lines = ["### Prompt-injection scan (specs / domain / onboarding)", `- Files scanned: ${inj.scanned ?? 0}`, `- Findings: ${inj.total ?? 0}`];
@@ -278,5 +295,6 @@ export function deterministicContextHasFindings(ctx) {
278
295
  if (ctx.circularDeps?.available && (ctx.circularDeps.total ?? 0) > 0) return true;
279
296
  if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
280
297
  if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
298
+ if (ctx.aiSlop?.available && (ctx.aiSlop.total ?? 0) > 0) return true;
281
299
  return false;
282
300
  }
@@ -2,11 +2,44 @@
2
2
  // One-shot container: detect Docker, auto-pull on first run (cache hit
3
3
  // thereafter), execute `assess <repo>` with the project mounted read-only.
4
4
  // No compose, no port discovery — assess is a single process that exits.
5
+ //
6
+ // KJC-BUG-0077 — classify failures (docker_missing / image_inaccessible /
7
+ // auth_required / network_error / unknown) and emit an actionable hint
8
+ // alongside the raw stderr so `kj audit` surfaces something better than
9
+ // the opaque "pull access denied" line the registry returns.
5
10
  import { runCommand } from "../utils/process.js";
6
11
 
7
12
  const D = { image: "markmishaev76/ai-harness-scorecard:latest", pullTimeoutMs: 300000, assessTimeoutMs: 180000 };
8
13
  const pos = (v, d) => (Number(v) > 0 ? Number(v) : d);
9
14
 
15
+ const DOCKER_FAILURE_PATTERNS = [
16
+ { reason: "image_inaccessible", re: /pull access denied|repository does not exist|manifest unknown|not found: manifest|denied: requested access/i },
17
+ { reason: "auth_required", re: /unauthorized|authentication required|login required|requires authentication/i },
18
+ { reason: "network_error", re: /no such host|connection refused|dial tcp|i\/o timeout|TLS handshake|net\/http|temporary failure in name resolution/i },
19
+ ];
20
+
21
+ export function classifyDockerFailure(stderr = "") {
22
+ const s = String(stderr);
23
+ for (const { reason, re } of DOCKER_FAILURE_PATTERNS) {
24
+ if (re.test(s)) return reason;
25
+ }
26
+ return "unknown";
27
+ }
28
+
29
+ export function hintForReason(reason, image) {
30
+ switch (reason) {
31
+ case "image_inaccessible":
32
+ return `Image \`${image}\` is not accessible from the public registry (it may have been removed, renamed, or made private). Either run \`kj audit --no-harness\` to skip this step, or override \`audit.harness.image\` with a mirror you control.`;
33
+ case "auth_required":
34
+ return `Pulling \`${image}\` requires registry authentication. Run \`docker login <registry>\` first, or override \`audit.harness.image\` with a public mirror.`;
35
+ case "network_error":
36
+ return `Network error reaching the registry while pulling \`${image}\`. Retry once connectivity is back; \`kj audit --no-harness\` skips this step in the meantime.`;
37
+ case "docker_missing":
38
+ return `Docker is not available on this machine. Install Docker (or run \`kj audit --no-harness\` to silence this).`;
39
+ default: return null;
40
+ }
41
+ }
42
+
10
43
  export function normalizeHarnessConfig(h = {}) {
11
44
  return {
12
45
  enabled: h.enabled !== false,
@@ -32,20 +65,36 @@ export async function ensureHarnessImage(harness = null) {
32
65
  const cfg = normalizeHarnessConfig(harness || {});
33
66
  if (await isHarnessImagePresent(cfg.image)) return { pulled: false, reused: true, image: cfg.image };
34
67
  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}` };
68
+ if (r.exitCode !== 0) {
69
+ const stderr = r.stderr?.trim() || `exit ${r.exitCode}`;
70
+ const reason = classifyDockerFailure(stderr);
71
+ return { pulled: false, reused: false, image: cfg.image, error: stderr, reason };
72
+ }
36
73
  return { pulled: true, reused: false, image: cfg.image };
37
74
  }
38
75
 
39
76
  export async function runHarnessAssess(repoPath, harness = null) {
40
77
  const cfg = normalizeHarnessConfig(harness || {});
78
+ if (!(await isDockerAvailable())) {
79
+ return { ok: false, reason: "docker_missing", image: cfg.image, error: "Docker daemon is not available", hint: hintForReason("docker_missing", cfg.image) };
80
+ }
81
+ const pull = await ensureHarnessImage(cfg);
82
+ if (pull.error) {
83
+ const reason = pull.reason || "pull_failed";
84
+ return { ok: false, reason, image: cfg.image, error: pull.error, hint: hintForReason(reason, cfg.image) };
85
+ }
41
86
  const r = await runCommand(
42
87
  "docker",
43
88
  ["run", "--rm", "-v", `${repoPath}:/repo:ro`, cfg.image, "assess", "/repo", "--format", "json"],
44
89
  { timeout: cfg.assessTimeoutMs },
45
90
  );
46
- if (r.exitCode !== 0) return { ok: false, error: r.stderr?.trim() || r.stdout?.trim() || `exit ${r.exitCode}` };
91
+ if (r.exitCode !== 0) {
92
+ const stderr = r.stderr?.trim() || r.stdout?.trim() || `exit ${r.exitCode}`;
93
+ const reason = classifyDockerFailure(stderr);
94
+ return { ok: false, reason, image: cfg.image, error: stderr, hint: hintForReason(reason, cfg.image) };
95
+ }
47
96
  try {
48
97
  const parsed = JSON.parse(r.stdout);
49
98
  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}` }; }
99
+ } catch (e) { return { ok: false, reason: "invalid_json", error: `Invalid JSON from harness: ${e.message}` }; }
51
100
  }
@@ -22,7 +22,7 @@ export async function runHarnessSection({ projectDir, harnessConfig = null, runA
22
22
  const cfg = normalizeHarnessConfig(harnessConfig || {});
23
23
  if (!cfg.enabled) return { ok: true, skipped: true, reason: "disabled" };
24
24
  const res = await runAssess(projectDir, cfg);
25
- if (!res.ok) return { ok: false, error: res.error };
25
+ if (!res.ok) return { ok: false, error: res.error, reason: res.reason, hint: res.hint, image: cfg.image };
26
26
  const outDir = path.join(projectDir, ".karajan", "audit-runs", ts());
27
27
  await fs.mkdir(outDir, { recursive: true });
28
28
  const scorecardPath = path.join(outDir, "scorecard.json");
@@ -39,7 +39,11 @@ export async function runHarnessSection({ projectDir, harnessConfig = null, runA
39
39
 
40
40
  export function formatHarnessSection(summary) {
41
41
  if (!summary || summary.skipped) return "";
42
- if (!summary.ok) return `## Harness Scorecard\n\n_skipped: ${summary.error}_\n`;
42
+ if (!summary.ok) {
43
+ const reason = summary.reason ? ` (${summary.reason})` : "";
44
+ const hint = summary.hint ? `\n\n${summary.hint}\n` : "";
45
+ return `## Harness Scorecard\n\n_skipped${reason}: ${summary.error}_${hint}\n`;
46
+ }
43
47
  const lines = [
44
48
  `## Harness Health Score: ${summary.score}/100 (${summary.grade})`,
45
49
  "",