karajan-code 3.1.0 → 3.3.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 (68) hide show
  1. package/README.md +12 -4
  2. package/package.json +6 -1
  3. package/packages/hu-board/package.json +3 -1
  4. package/packages/hu-board/public/index.html +1 -0
  5. package/packages/hu-board/public/styles.css +32 -0
  6. package/packages/hu-board/public/utils/board-view.js +21 -1
  7. package/packages/hu-board/public/utils/formatters.js +68 -0
  8. package/packages/hu-board/public/wiki.html +32 -0
  9. package/packages/hu-board/public/wiki.js +53 -0
  10. package/packages/hu-board/src/db.js +137 -0
  11. package/packages/hu-board/src/format.js +124 -0
  12. package/packages/hu-board/src/plan-mutations.js +4 -4
  13. package/packages/hu-board/src/routes/api.js +16 -1
  14. package/packages/hu-board/src/routes/rag.js +1 -1
  15. package/packages/hu-board/src/routes/wiki.js +43 -0
  16. package/packages/hu-board/src/run-tracker.js +1 -1
  17. package/packages/hu-board/src/server.js +4 -2
  18. package/packages/hu-board/src/sync.js +1 -1
  19. package/scripts/git-hooks/post-merge +13 -2
  20. package/src/agents/aider-agent.js +42 -1
  21. package/src/agents/claude-agent.js +14 -3
  22. package/src/agents/codex-agent.js +44 -7
  23. package/src/agents/gemini-agent.js +37 -1
  24. package/src/agents/opencode-agent.js +34 -1
  25. package/src/audit/ai-slop-findings.js +102 -0
  26. package/src/audit/deterministic-summary.js +18 -0
  27. package/src/brain/standby-scheduler.js +10 -92
  28. package/src/brain/standby-store.js +12 -168
  29. package/src/checks/ai-trash.js +34 -0
  30. package/src/checks/qmd.js +41 -0
  31. package/src/checks/squeezr.js +36 -0
  32. package/src/cli/register-meta.js +21 -0
  33. package/src/cli/register-pipeline.js +3 -0
  34. package/src/commands/audit.js +6 -4
  35. package/src/commands/board.js +34 -0
  36. package/src/commands/doctor.js +6 -0
  37. package/src/commands/init.js +73 -8
  38. package/src/commands/qmd.js +86 -0
  39. package/src/commands/resume.js +6 -45
  40. package/src/commands/run.js +6 -50
  41. package/src/git/hu-snapshot.js +10 -105
  42. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
  43. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  44. package/src/orchestrator/flow-runner.js +10 -2
  45. package/src/orchestrator/hu-sub-pipeline.js +82 -8
  46. package/src/plan/plan-hu-ops.js +16 -338
  47. package/src/plan/plan-id.js +4 -45
  48. package/src/plan/plan-validation.js +4 -82
  49. package/src/rag/vec-store.js +17 -253
  50. package/src/roles/audit-role.js +11 -1
  51. package/src/session/journal/summary-writer.js +55 -1
  52. package/src/utils/ai-trash.js +48 -0
  53. package/src/utils/atomic-write.js +6 -43
  54. package/src/utils/budget.js +38 -9
  55. package/src/utils/cli-ask-question.js +76 -0
  56. package/src/utils/doc-links.js +2 -0
  57. package/src/utils/os-detect.js +10 -0
  58. package/src/utils/paths.js +14 -123
  59. package/src/utils/port-check.js +6 -45
  60. package/src/utils/process.js +4 -188
  61. package/src/utils/qmd-collection.js +72 -0
  62. package/src/utils/qmd-detect.js +18 -0
  63. package/src/utils/qmd-install.js +37 -0
  64. package/src/utils/run-registry.js +9 -116
  65. package/src/utils/shared-paths.js +12 -41
  66. package/src/utils/squeezr-detect.js +18 -0
  67. package/src/utils/squeezr-install.js +37 -0
  68. package/src/utils/telemetry.js +43 -0
@@ -17,13 +17,13 @@
17
17
  import { readFileSync, existsSync, readdirSync, mkdirSync, openSync } from 'node:fs';
18
18
  import { join, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
- import { writeJsonAtomicSync } from '../../../src/utils/atomic-write.js';
20
+ import { writeJsonAtomicSync } from '@karajan/core/atomic-write';
21
21
  import { spawn } from 'node:child_process';
22
22
  import { trackRun, untrack } from './run-tracker.js';
23
23
  import { getHuBoardRunsDir } from './db.js';
24
24
  import { fileURLToPath } from 'node:url';
25
- import { updateHuStatus, certifyAllHus, updateHu } from '../../../src/plan/plan-hu-ops.js';
26
- import { validateBlockedByChange } from '../../../src/plan/plan-validation.js';
25
+ import { updateHuStatus, certifyAllHus, updateHu } from '@karajan/core/plan-hu-ops';
26
+ import { validateBlockedByChange } from '@karajan/core/plan-validation';
27
27
  import { syncPlanFile } from './sync.js';
28
28
  import { getDb } from './db.js';
29
29
 
@@ -230,7 +230,7 @@ export async function revertHuFromSnapshot({ planId, huId, projectId }) {
230
230
  const projectDir = plan.projectDir;
231
231
  if (!projectDir) return { ok: false, error: 'plan sin projectDir' };
232
232
 
233
- const { restoreHuSnapshot } = await import('../../../src/git/hu-snapshot.js');
233
+ const { restoreHuSnapshot } = await import('@karajan/core/hu-snapshot');
234
234
  const restore = await restoreHuSnapshot({ projectDir, huId });
235
235
  if (!restore.ok) return { ok: false, error: `restore falló: ${restore.error}` };
236
236
 
@@ -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
  */
@@ -1325,7 +1340,7 @@ router.post('/rag/query', async (req, res) => {
1325
1340
  return res.status(400).json({ error: "'text' is required (non-empty string)" });
1326
1341
  }
1327
1342
  try {
1328
- const { openVecStore, countChunks } = await import('../../../../src/rag/vec-store.js');
1343
+ const { openVecStore, countChunks } = await import('@karajan/core/vec-store');
1329
1344
  const { makeEmbedder } = await import('../../../../src/rag/embedders/factory.js');
1330
1345
  const { query } = await import('../../../../src/rag/retriever.js');
1331
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
  /**
@@ -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
@@ -1,6 +1,46 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
2
  import { resolveBin } from "./resolve-bin.js";
3
3
 
4
+ /**
5
+ * Extract token usage from Aider's stdout. Two shapes are supported:
6
+ *
7
+ * 1. LiteLLM passthrough — an OpenAI-style JSON `usage` block surfaces
8
+ * `prompt_tokens_details.cached_tokens` for prompt-cache hits.
9
+ * 2. Aider's native footer "Tokens: <sent> sent, <received> received
10
+ * [, <cached> cache hit]." (supports comma-separated and `k` suffix).
11
+ *
12
+ * Returns `{tokens_in, tokens_out, cached_tokens}` or null when no usage
13
+ * is found (= unmeasured; callers leave the fields undefined).
14
+ */
15
+ export function extractAiderUsage(text) {
16
+ if (!text) return null;
17
+ for (const line of text.split("\n")) {
18
+ const t = line.trim();
19
+ if (!t.startsWith("{")) continue;
20
+ let obj;
21
+ try { obj = JSON.parse(t); } catch { continue; }
22
+ const u = obj?.usage ?? (typeof obj?.prompt_tokens === "number" ? obj : null);
23
+ if (!u || typeof u.prompt_tokens !== "number") continue;
24
+ return {
25
+ tokens_in: u.prompt_tokens,
26
+ tokens_out: u.completion_tokens ?? 0,
27
+ cached_tokens: u.prompt_tokens_details?.cached_tokens ?? 0
28
+ };
29
+ }
30
+ const m = text.match(/Tokens:\s*([\d.,]+k?)\s*sent,\s*([\d.,]+k?)\s*received(?:,\s*([\d.,]+k?)\s*cache\s*hit)?/i);
31
+ if (!m) return null;
32
+ const parse = (s) => {
33
+ const c = s.replaceAll(",", "");
34
+ return c.toLowerCase().endsWith("k")
35
+ ? Math.round(parseFloat(c.slice(0, -1)) * 1000)
36
+ : Number(c);
37
+ };
38
+ const sent = parse(m[1]);
39
+ const recv = parse(m[2]);
40
+ if (!Number.isFinite(sent) || !Number.isFinite(recv)) return null;
41
+ return { tokens_in: sent, tokens_out: recv, cached_tokens: m[3] ? parse(m[3]) : 0 };
42
+ }
43
+
4
44
  export class AiderAgent extends BaseAgent {
5
45
  async runTask(task) {
6
46
  const role = task.role || "coder";
@@ -32,6 +72,7 @@ export class AiderAgent extends BaseAgent {
32
72
  silenceTimeoutMs: task.silenceTimeoutMs,
33
73
  timeout: task.timeoutMs
34
74
  });
35
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
75
+ const usage = extractAiderUsage(res.stdout) ?? extractAiderUsage(res.stderr);
76
+ return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
36
77
  }
37
78
  }
@@ -68,8 +68,16 @@ function collectAssistantText(obj) {
68
68
  /**
69
69
  * Extract usage metrics from stream-json/json NDJSON output.
70
70
  * Looks for the "result" line which contains total_cost_usd,
71
- * usage.input_tokens/output_tokens, and modelUsage.
72
- * Returns an object with tokens_in, tokens_out, cost_usd, model or null if not found.
71
+ * usage.input_tokens/output_tokens, cache_read_input_tokens,
72
+ * cache_creation_input_tokens, and modelUsage.
73
+ *
74
+ * `cached_tokens` aggregates both cache_read_input_tokens (tokens served
75
+ * from Anthropic's automatic prompt cache) and cache_creation_input_tokens
76
+ * (tokens just written to the cache during this call). The sum represents
77
+ * the total volume of input that benefited from prompt-caching machinery.
78
+ *
79
+ * Returns an object with tokens_in, tokens_out, cached_tokens, cost_usd,
80
+ * model or null if not found.
73
81
  */
74
82
  function extractUsageFromStreamJson(raw) {
75
83
  const events = parseClaudeEvents(raw);
@@ -79,11 +87,14 @@ function extractUsageFromStreamJson(raw) {
79
87
 
80
88
  const tokens_in = obj.usage?.input_tokens ?? 0;
81
89
  const tokens_out = obj.usage?.output_tokens ?? 0;
90
+ const cache_read = obj.usage?.cache_read_input_tokens ?? 0;
91
+ const cache_creation = obj.usage?.cache_creation_input_tokens ?? 0;
92
+ const cached_tokens = cache_read + cache_creation;
82
93
  const cost_usd = obj.total_cost_usd ?? undefined;
83
94
  const modelUsage = obj.modelUsage;
84
95
  const model = modelUsage ? Object.keys(modelUsage)[0] || null : null;
85
96
 
86
- return { tokens_in, tokens_out, cost_usd, model };
97
+ return { tokens_in, tokens_out, cached_tokens, cost_usd, model };
87
98
  }
88
99
  return null;
89
100
  }
@@ -2,18 +2,55 @@ import { BaseAgent } from "./base-agent.js";
2
2
  import { resolveBin } from "./resolve-bin.js";
3
3
 
4
4
  /**
5
- * Extract token usage from Codex CLI stdout.
6
- * Codex prints "tokens used\n<number>" at the end, where number may have comma separators.
7
- * Returns { tokens_out } with the total token count, or null if not found.
8
- * Since Codex doesn't split input/output, we assign the total to tokens_out
9
- * as a conservative estimate for cost calculation.
5
+ * Try to extract an OpenAI-style `usage` JSON block from codex stdout.
6
+ *
7
+ * OpenAI's Chat Completions API exposes prompt-cache hits via
8
+ * `usage.prompt_tokens_details.cached_tokens` (since Aug 2024). Codex
9
+ * CLI doesn't surface this in its default footer, but newer flags or a
10
+ * wrapper may emit a JSON `usage` line. We scan stdout line-by-line and
11
+ * accept the first parseable object that carries `prompt_tokens`.
12
+ *
13
+ * Returns `{tokens_in, tokens_out, cached_tokens}` or null when absent.
14
+ */
15
+ function extractJsonUsage(stdout) {
16
+ for (const line of stdout.split("\n")) {
17
+ const trimmed = line.trim();
18
+ if (!trimmed.startsWith("{")) continue;
19
+ let obj;
20
+ try { obj = JSON.parse(trimmed); } catch { continue; }
21
+ const usage = obj?.usage ?? (typeof obj?.prompt_tokens === "number" ? obj : null);
22
+ if (!usage || typeof usage.prompt_tokens !== "number") continue;
23
+ return {
24
+ tokens_in: usage.prompt_tokens,
25
+ tokens_out: usage.completion_tokens ?? 0,
26
+ cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0
27
+ };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Extract token usage from Codex CLI stdout. Two shapes are supported:
34
+ *
35
+ * 1. OpenAI-style JSON `usage` block (preferred):
36
+ * `{"usage":{"prompt_tokens":N,"completion_tokens":M,
37
+ * "prompt_tokens_details":{"cached_tokens":C}}}`
38
+ * → `{tokens_in:N, tokens_out:M, cached_tokens:C}`.
39
+ *
40
+ * 2. Legacy "tokens used\n<number>" footer (codex's default total,
41
+ * no cache visibility) → `{tokens_in:0, tokens_out, cached_tokens:0}`.
42
+ *
43
+ * Returns null when neither shape is found.
10
44
  */
11
45
  function extractCodexTokens(stdout) {
12
- const match = (stdout || "").match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
46
+ if (!stdout) return null;
47
+ const json = extractJsonUsage(stdout);
48
+ if (json) return json;
49
+ const match = stdout.match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
13
50
  if (!match) return null;
14
51
  const total = Number(match[1].replaceAll(/,/g, ""));
15
52
  if (!Number.isFinite(total) || total <= 0) return null;
16
- return { tokens_in: 0, tokens_out: total };
53
+ return { tokens_in: 0, tokens_out: total, cached_tokens: 0 };
17
54
  }
18
55
 
19
56
  export class CodexAgent extends BaseAgent {
@@ -1,6 +1,41 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
2
  import { resolveBin } from "./resolve-bin.js";
3
3
 
4
+ /**
5
+ * Extract Gemini's `usageMetadata` from stdout, including
6
+ * `cachedContentTokenCount` (Gemini's context-caching API metric,
7
+ * https://ai.google.dev/api/caching).
8
+ *
9
+ * The gemini CLI emits `usageMetadata` as part of its JSON response
10
+ * shape (today: only `--output-format json`, used in reviewTask; in
11
+ * plain runTask mode no usage is surfaced). When absent, returns null
12
+ * so the caller leaves `cached_tokens` undefined (= unmeasured,
13
+ * distinct from a measured 0).
14
+ */
15
+ export function extractGeminiUsage(text) {
16
+ if (!text) return null;
17
+ const candidates = [];
18
+ for (const line of text.split("\n")) {
19
+ const t = line.trim();
20
+ if (t.startsWith("{") || t.startsWith("[")) candidates.push(t);
21
+ }
22
+ candidates.push(text.trim());
23
+ for (const candidate of candidates) {
24
+ let obj;
25
+ try { obj = JSON.parse(candidate); } catch { continue; }
26
+ const meta = obj?.usageMetadata
27
+ ?? obj?.usage_metadata
28
+ ?? obj?.response?.usageMetadata;
29
+ if (!meta || typeof meta.promptTokenCount !== "number") continue;
30
+ return {
31
+ tokens_in: meta.promptTokenCount,
32
+ tokens_out: meta.candidatesTokenCount ?? 0,
33
+ cached_tokens: meta.cachedContentTokenCount ?? 0
34
+ };
35
+ }
36
+ return null;
37
+ }
38
+
4
39
  export class GeminiAgent extends BaseAgent {
5
40
  async runTask(task) {
6
41
  const role = task.role || "coder";
@@ -33,6 +68,7 @@ export class GeminiAgent extends BaseAgent {
33
68
  silenceTimeoutMs: task.silenceTimeoutMs,
34
69
  timeout: task.timeoutMs
35
70
  });
36
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
71
+ const usage = extractGeminiUsage(res.stdout) ?? extractGeminiUsage(res.stderr);
72
+ return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
37
73
  }
38
74
  }
@@ -1,6 +1,38 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
2
  import { resolveBin } from "./resolve-bin.js";
3
3
 
4
+ /**
5
+ * Extract OpenAI-style `usage` from OpenCode stdout. With `--format json`
6
+ * (used in reviewTask) the CLI emits a JSON document; with LiteLLM-backed
7
+ * runs that document carries `usage.prompt_tokens_details.cached_tokens`.
8
+ *
9
+ * Accepts the whole payload, individual lines, or array elements with a
10
+ * nested `usage` object. Returns `{tokens_in, tokens_out, cached_tokens}`
11
+ * or null (= unmeasured).
12
+ */
13
+ export function extractOpenCodeUsage(text) {
14
+ if (!text) return null;
15
+ const candidates = [text.trim()];
16
+ for (const line of text.split("\n")) {
17
+ const t = line.trim();
18
+ if (t.startsWith("{") || t.startsWith("[")) candidates.push(t);
19
+ }
20
+ for (const candidate of candidates) {
21
+ let obj;
22
+ try { obj = JSON.parse(candidate); } catch { continue; }
23
+ const usage = obj?.usage
24
+ ?? obj?.result?.usage
25
+ ?? (Array.isArray(obj) ? obj.find((e) => e?.usage)?.usage : null);
26
+ if (!usage || typeof usage.prompt_tokens !== "number") continue;
27
+ return {
28
+ tokens_in: usage.prompt_tokens,
29
+ tokens_out: usage.completion_tokens ?? 0,
30
+ cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0
31
+ };
32
+ }
33
+ return null;
34
+ }
35
+
4
36
  export class OpenCodeAgent extends BaseAgent {
5
37
  async runTask(task) {
6
38
  const role = task.role || "coder";
@@ -34,6 +66,7 @@ export class OpenCodeAgent extends BaseAgent {
34
66
  silenceTimeoutMs: task.silenceTimeoutMs,
35
67
  timeout: task.timeoutMs
36
68
  });
37
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
69
+ const usage = extractOpenCodeUsage(res.stdout) ?? extractOpenCodeUsage(res.stderr);
70
+ return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
38
71
  }
39
72
  }
@@ -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
  }