karajan-code 3.1.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 (59) 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 +10 -1
  7. package/packages/hu-board/public/utils/formatters.js +41 -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 +78 -0
  11. package/packages/hu-board/src/format.js +79 -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/audit/ai-slop-findings.js +102 -0
  21. package/src/audit/deterministic-summary.js +18 -0
  22. package/src/brain/standby-scheduler.js +10 -92
  23. package/src/brain/standby-store.js +12 -168
  24. package/src/checks/ai-trash.js +34 -0
  25. package/src/checks/qmd.js +41 -0
  26. package/src/checks/squeezr.js +36 -0
  27. package/src/cli/register-meta.js +21 -0
  28. package/src/cli/register-pipeline.js +3 -0
  29. package/src/commands/audit.js +6 -4
  30. package/src/commands/board.js +34 -0
  31. package/src/commands/doctor.js +6 -0
  32. package/src/commands/init.js +73 -8
  33. package/src/commands/qmd.js +86 -0
  34. package/src/commands/resume.js +6 -45
  35. package/src/commands/run.js +6 -50
  36. package/src/git/hu-snapshot.js +10 -105
  37. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
  38. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  39. package/src/orchestrator/hu-sub-pipeline.js +52 -8
  40. package/src/plan/plan-hu-ops.js +16 -338
  41. package/src/plan/plan-id.js +4 -45
  42. package/src/plan/plan-validation.js +4 -82
  43. package/src/rag/vec-store.js +17 -253
  44. package/src/roles/audit-role.js +11 -1
  45. package/src/utils/ai-trash.js +48 -0
  46. package/src/utils/atomic-write.js +6 -43
  47. package/src/utils/cli-ask-question.js +76 -0
  48. package/src/utils/doc-links.js +2 -0
  49. package/src/utils/os-detect.js +10 -0
  50. package/src/utils/paths.js +14 -123
  51. package/src/utils/port-check.js +6 -45
  52. package/src/utils/process.js +4 -188
  53. package/src/utils/qmd-collection.js +72 -0
  54. package/src/utils/qmd-detect.js +18 -0
  55. package/src/utils/qmd-install.js +37 -0
  56. package/src/utils/run-registry.js +9 -116
  57. package/src/utils/shared-paths.js +12 -41
  58. package/src/utils/squeezr-detect.js +18 -0
  59. package/src/utils/squeezr-install.js +37 -0
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v3.1.0 released** — first minor on v3. Bundles tool-correctness judge and TDD-discipline as new quality gates, `kj clean --all/--vector-stores/--repo` for housekeeping, `kj sync --apply` closing the SPDD loop, semantic test-diet auditor (498 test files, 0 loss-of-meaning findings), HU Board structural refactor (17 PRs), and a batch of security + flake fixes. No breaking changes; drop-in upgrade from v3.0.0. Full notes in [CHANGELOG.md](CHANGELOG.md).
26
+ > **v3.2.0 released** — Cost tracking end-to-end (epic KJC-PCS-0055 closed). Every `kj run` now records per-HU USD spend via the `BudgetTracker` cursor-snapshot pattern, persists it to `board.db.cost_usd`, and the HU Board surfaces both a per-card badge (`$0.02`) and a project-wide chip with per-plan breakdown. Null-safe: unmeasured HUs hide the badge instead of misleading "$0.00". Drop-in upgrade from v3.1.0. 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
 
@@ -121,7 +121,7 @@ pip install .
121
121
 
122
122
  Both options expect the `kj` binary to be on your `PATH` (via one of the methods above) — the Python wrapper does not vendor it.
123
123
 
124
- That's it. `kj init` auto-detects your installed agents and installs RTK for token optimization.
124
+ That's it. `kj init` auto-detects your installed agents and installs the bundled integrations (RTK + Squeezr for token optimization; QMD for the per-project semantic wiki).
125
125
 
126
126
  ### Optional scanners for `kj audit` + `kj webperf`
127
127
 
@@ -366,14 +366,22 @@ Karajan auto-detects and auto-configures everything it can:
366
366
 
367
367
  No per-project configuration required. If you want to customize, config is layered: session > project > global.
368
368
 
369
+ ## Bundled integrations
370
+
371
+ These ship with Karajan and `kj init` installs them as part of setup. They're token-efficiency layers the pipeline depends on — not nice-to-haves.
372
+
373
+ | Tool | Invoked by | Why |
374
+ |------|-----------|-----|
375
+ | [**RTK**](https://github.com/rtk-ai/rtk) | Karajan (auto, on Bash outputs) | Reduces token consumption by 60-90% on Bash command outputs |
376
+ | [**Squeezr**](https://www.npmjs.com/package/squeezr-ai) | Karajan (auto, on agent tool outputs) | Compresses verbose tool results before they reach the agent context |
377
+ | [**QMD**](https://github.com/manufosela/qmd) | Karajan (`kj init` auto-registers `docs/`, `.reviews/`, `.karajan/plans/`); you (via `kj qmd query`) | Per-project semantic wiki for **humans**. Complements the **agent-facing** RAG index — `kj rag query` is what the pipeline reads; `kj qmd query` is what you read |
378
+
369
379
  ## Recommended companions
370
380
 
371
381
  None of these are required. Karajan runs fine on its own. They're tools that, when present, Karajan can take advantage of — or that help *you* work better around Karajan.
372
382
 
373
383
  | Tool | Invoked by | Why |
374
384
  |------|-----------|-----|
375
- | [**RTK**](https://github.com/rtk-ai/rtk) | Karajan (auto, on Bash outputs) | Reduces token consumption by 60-90% on Bash command outputs |
376
- | [**QMD**](https://github.com/manufosela/qmd) | You (CLI / MCP), complementary to RAG | Semantic search engine over Markdown corpora — works alongside `kj rag query` when you want a richer index over your own docs |
377
385
  | [**GitHub MCP**](https://github.com/github/github-mcp-server) | Your AI agent (via MCP) | Create PRs, manage issues directly from the agent |
378
386
  | [**Chrome DevTools MCP**](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Your AI agent (via MCP) | Verify UI changes visually after frontend modifications |
379
387
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -26,6 +26,11 @@
26
26
  "engines": {
27
27
  "node": ">=22.22.1"
28
28
  },
29
+ "workspaces": [
30
+ "packages/ai-trash",
31
+ "packages/core",
32
+ "packages/hu-board"
33
+ ],
29
34
  "imports": {
30
35
  "#utils/*": "./src/utils/*",
31
36
  "#session/*": "./src/session/*",
@@ -13,11 +13,13 @@
13
13
  "test": "vitest run"
14
14
  },
15
15
  "dependencies": {
16
+ "@karajan/core": "*",
16
17
  "better-sqlite3": "^12.10.0",
17
18
  "chokidar": "^5.0.0",
18
19
  "express": "^5.1.0",
19
20
  "express-rate-limit": "^8.5.0",
20
- "helmet": "^8.1.0"
21
+ "helmet": "^8.1.0",
22
+ "js-yaml": "^4.2.0"
21
23
  },
22
24
  "devDependencies": {
23
25
  "supertest": "^7.1.0",
@@ -26,6 +26,7 @@
26
26
  <button class="nav-btn" data-view="sessions" title="Every kj run that has touched the board, newest first. Filter by project; click a session to inspect its iterations, commits and checkpoints.">Sessions</button>
27
27
  <a class="nav-btn" href="/pipeline.html" title="Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs.">Pipeline</a>
28
28
  <a class="nav-btn" href="/rag.html" title="RAG retrieval-quality dashboard: chunk counts, embedder provider, db size.">RAG</a>
29
+ <a class="nav-btn" href="/wiki.html" title="Search the per-project QMD semantic wiki (docs, plans, reviews).">Wiki</a>
29
30
  <select class="project-select" id="project-select">
30
31
  <option value="">All Projects</option>
31
32
  </select>
@@ -244,6 +244,17 @@ a:hover {
244
244
  font-family: var(--font-mono);
245
245
  }
246
246
 
247
+ .section-header__cost {
248
+ font-size: 0.8rem;
249
+ color: var(--text-muted);
250
+ font-family: var(--font-mono);
251
+ padding: 2px 8px;
252
+ border: 1px solid var(--border);
253
+ border-radius: var(--radius-sm);
254
+ background: var(--bg-secondary, transparent);
255
+ cursor: help;
256
+ }
257
+
247
258
  /* ---- Kanban Board ---- */
248
259
  .kanban {
249
260
  display: grid;
@@ -341,6 +352,12 @@ a:hover {
341
352
  .story-card__score--ok { color: var(--color-yellow); }
342
353
  .story-card__score--bad { color: var(--color-red); }
343
354
 
355
+ .story-card__cost {
356
+ font-family: var(--font-mono);
357
+ font-size: 0.75rem;
358
+ color: var(--text-muted);
359
+ }
360
+
344
361
  .quality-bar {
345
362
  display: inline-flex;
346
363
  gap: 1px;
@@ -939,3 +956,18 @@ body.scoped-mode .header__nav a[href="/pipeline.html"] {
939
956
  .rag-bar__label { color: var(--text-secondary, #cbd5e1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
940
957
  .rag-bar__fill { display: block; height: 14px; background: linear-gradient(90deg, #7c3aed, #a78bfa); border-radius: 3px; }
941
958
  .rag-bar__value { text-align: right; color: var(--text-primary, #f8fafc); font-variant-numeric: tabular-nums; }
959
+
960
+ /* KJC-TSK-0508 — Wiki panel (QMD semantic search) */
961
+ .wiki-search { padding: 1rem; }
962
+ .wiki-form { display: grid; grid-template-columns: 1fr 220px auto auto; gap: .5rem; align-items: center; }
963
+ .wiki-form input[type="search"], .wiki-form input[type="text"] { padding: .5rem .75rem; border-radius: 6px; border: 1px solid var(--border, #334155); background: var(--bg-secondary, #1e1e2e); color: var(--text-primary, #f8fafc); font-size: .9rem; }
964
+ .wiki-fast { display: flex; align-items: center; gap: .35rem; font-size: .85rem; color: var(--text-secondary, #94a3b8); }
965
+ .wiki-hint { margin: .5rem 0 0; font-size: .8rem; color: var(--text-secondary, #94a3b8); font-style: italic; }
966
+ .wiki-results { display: grid; gap: .75rem; padding: 0 1rem 1rem; }
967
+ .wiki-hit { background: var(--bg-secondary, #1e1e2e); border-radius: 8px; padding: .9rem 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
968
+ .wiki-hit__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .4rem; }
969
+ .wiki-hit__src { font-family: ui-monospace, monospace; font-size: .85rem; color: #a78bfa; }
970
+ .wiki-hit__score { font-size: .75rem; color: var(--text-secondary, #94a3b8); font-variant-numeric: tabular-nums; }
971
+ .wiki-hit__snip { margin: 0; font-family: ui-monospace, monospace; font-size: .82rem; line-height: 1.45; color: var(--text-primary, #f8fafc); white-space: pre-wrap; word-break: break-word; }
972
+ .wiki-code { background: #0f172a; color: #e2e8f0; padding: .5rem .75rem; border-radius: 6px; font-family: ui-monospace, monospace; font-size: .85rem; margin: .5rem 0; }
973
+ @media (max-width: 720px) { .wiki-form { grid-template-columns: 1fr; } }
@@ -35,7 +35,11 @@ async function renderBoard() {
35
35
  return;
36
36
  }
37
37
 
38
- const stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
38
+ const [stories, projectCost] = await Promise.all([
39
+ api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`),
40
+ api(`/api/projects/${encodeURIComponent(selectedProject)}/cost`).catch(() => null),
41
+ ]);
42
+ const costSummary = projectCost ? formatProjectCostSummary(projectCost) : null;
39
43
 
40
44
  // Pre-resolve project initials + name for every distinct project_id in
41
45
  // the fetched stories so `renderStoryCard` is synchronous and the header
@@ -96,6 +100,7 @@ async function renderBoard() {
96
100
  onclick="event.stopPropagation(); window.renameProjectModal('${esc(selectedProject)}', '${esc(projectDisplayName.replace(/'/g, '&#39;'))}');">✎</button>
97
101
  ` : ''}
98
102
  <span class="section-header__count">${stories.length} stories</span>
103
+ ${costSummary ? `<span class="section-header__cost" title="${esc(costSummary.tooltip)}">💵 ${esc(costSummary.label)}</span>` : ''}
99
104
  ${isRunning ? `
100
105
  <button id="running-badge-btn" class="section-header__badge"
101
106
  style="margin-left:auto;padding:4px 10px;font-size:0.8rem;background:var(--color-yellow,#eab308);color:#000;border-radius:var(--radius-sm);font-weight:600;border:none;cursor:pointer;"
@@ -372,6 +377,10 @@ function renderStoryCard(story) {
372
377
  ${story.quality_total}/60 ${qualityBar(story.quality_total)}
373
378
  </span>
374
379
  ` : ''}
380
+ ${(() => {
381
+ const c = formatCost(story.cost_usd);
382
+ return c ? `<span class="story-card__cost" title="${esc(c.tooltip)}">💵 ${esc(c.label)}</span>` : '';
383
+ })()}
375
384
  </div>
376
385
  ${missingTestContract ? `
377
386
  <div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--color-yellow,#eab308);font-weight:600" title="This HU has no acceptance_tests declared — Run plan will reject it until you add at least one.">
@@ -45,6 +45,47 @@ function formatSessionLabel(session) {
45
45
  };
46
46
  }
47
47
 
48
+ function formatCost(costUsd) {
49
+ if (costUsd === null || costUsd === undefined) return null;
50
+ const n = Number(costUsd);
51
+ if (!Number.isFinite(n)) return null;
52
+ return {
53
+ label: `$${n.toFixed(2)}`,
54
+ tooltip: `Estimated cost: $${n.toFixed(4)}`,
55
+ };
56
+ }
57
+
58
+ function formatProjectCostSummary(cost) {
59
+ if (!cost || typeof cost !== "object") return null;
60
+ const total = Number(cost.totalUsd);
61
+ if (!Number.isFinite(total)) return null;
62
+ const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
63
+ if (total === 0 && byPlan.length === 0) return null;
64
+
65
+ const lines = [`Total: $${total.toFixed(4)}`];
66
+ if (byPlan.length > 0) {
67
+ lines.push("By plan:");
68
+ for (const p of byPlan) {
69
+ const planTotal = Number(p?.totalUsd);
70
+ if (!Number.isFinite(planTotal)) continue;
71
+ const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
72
+ const planLabel = p?.planId || "unassigned";
73
+ lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})`);
74
+ }
75
+ }
76
+ const unk = cost.unknownModelTokens;
77
+ if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
78
+ const unkTotal = unk.tokensIn + unk.tokensOut;
79
+ if (unkTotal > 0) {
80
+ lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
81
+ }
82
+ }
83
+ return {
84
+ label: `Total: $${total.toFixed(2)}`,
85
+ tooltip: lines.join("\n"),
86
+ };
87
+ }
88
+
48
89
  function humaniseProjectName(id) {
49
90
  if (!id || typeof id !== 'string') return id || '';
50
91
  const tail = id.split(/[/_]/).filter(Boolean).pop() || id;
@@ -0,0 +1,32 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Karajan Wiki · QMD semantic search</title>
7
+ <link rel="stylesheet" href="/styles.css">
8
+ </head>
9
+ <body>
10
+ <header class="header">
11
+ <div class="header__logo"><div><div class="header__title">Karajan Wiki</div><div class="header__subtitle">QMD semantic search</div></div></div>
12
+ <nav class="header__nav">
13
+ <a class="nav-btn" href="/">← Board</a>
14
+ <a class="nav-btn" href="/rag.html">RAG</a>
15
+ <button class="nav-btn active">Wiki</button>
16
+ </nav>
17
+ </header>
18
+ <main class="main" id="wiki-app">
19
+ <section class="wiki-search">
20
+ <form id="wiki-form" class="wiki-form" autocomplete="off">
21
+ <input id="wiki-q" type="search" placeholder="Search the project wiki…" aria-label="Query" required>
22
+ <input id="wiki-collection" type="text" placeholder="collection (optional)" aria-label="Collection">
23
+ <label class="wiki-fast"><input id="wiki-fast" type="checkbox"> fast (no rerank)</label>
24
+ <button type="submit" class="control-btn" id="wiki-go">Search</button>
25
+ </form>
26
+ <p class="wiki-hint">QMD runs locally — 0 tokens, ~60s with rerank, ~5s in fast mode.</p>
27
+ </section>
28
+ <section id="wiki-results" class="wiki-results"></section>
29
+ </main>
30
+ <script src="/wiki.js"></script>
31
+ </body>
32
+ </html>
@@ -0,0 +1,53 @@
1
+ // KJC-TSK-0508 — HU Board Wiki panel frontend. Calls POST /api/wiki/query
2
+ // (server-side spawns `qmd query --format json`) and renders the hits.
3
+ // A 503 with `installable:true` triggers an "Install QMD" CTA instead of
4
+ // a generic error so the user can act on the missing binary.
5
+ const TOKEN = new URLSearchParams(location.search).get("token") || localStorage.getItem("kj_board_token") || "";
6
+ const HEADERS = { "Content-Type": "application/json", ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}) };
7
+ const ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
8
+
9
+ function renderInstallCTA(root) {
10
+ root.innerHTML = `<div class="rag-card rag-card--error">
11
+ <h3>QMD not installed</h3>
12
+ <p>The semantic wiki engine is not available on this host. Install it once:</p>
13
+ <pre class="wiki-code">npm install -g @tobilu/qmd</pre>
14
+ <p class="rag-stat-sub">Then re-run <code>kj init</code> in this project to register the wiki collection.</p>
15
+ </div>`;
16
+ }
17
+
18
+ function renderHits(root, results) {
19
+ if (!Array.isArray(results) || results.length === 0) {
20
+ root.innerHTML = '<p class="rag-empty">no hits — try a broader query or run <code>qmd update</code>.</p>';
21
+ return;
22
+ }
23
+ root.innerHTML = results.map((r) => {
24
+ const src = r.source || r.path || r.file || "(unknown source)";
25
+ const snip = r.snippet || r.text || r.content || "";
26
+ const score = typeof r.score === "number" ? r.score.toFixed(3) : "";
27
+ return `<article class="wiki-hit">
28
+ <header class="wiki-hit__head"><span class="wiki-hit__src">${ESC(src)}</span>${score ? `<span class="wiki-hit__score">${ESC(score)}</span>` : ""}</header>
29
+ <pre class="wiki-hit__snip">${ESC(snip)}</pre>
30
+ </article>`;
31
+ }).join("");
32
+ }
33
+
34
+ async function runSearch(ev) {
35
+ ev?.preventDefault();
36
+ const root = document.getElementById("wiki-results");
37
+ const query = document.getElementById("wiki-q").value.trim();
38
+ if (!query) return;
39
+ const collection = document.getElementById("wiki-collection").value.trim() || undefined;
40
+ const fast = document.getElementById("wiki-fast").checked;
41
+ root.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Searching the wiki…</p></div>';
42
+ try {
43
+ const res = await fetch("/api/wiki/query", { method: "POST", headers: HEADERS, body: JSON.stringify({ query, collection, fast }) });
44
+ const data = await res.json().catch(() => ({}));
45
+ if (res.status === 503 && data.installable) { renderInstallCTA(root); return; }
46
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
47
+ renderHits(root, data.results);
48
+ } catch (err) {
49
+ root.innerHTML = `<div class="rag-card rag-card--error"><strong>Error:</strong> ${ESC(err.message || String(err))}</div>`;
50
+ }
51
+ }
52
+
53
+ document.getElementById("wiki-form").addEventListener("submit", runSearch);
@@ -323,6 +323,11 @@ export function initDb() {
323
323
  // unconditionally so solo devs who later promote a plan don't lose
324
324
  // values they typed pre-share.
325
325
  try { db.exec('ALTER TABLE stories ADD COLUMN assignee TEXT'); } catch { /* already migrated */ }
326
+ // KJC-TSK-0514 (Cost C): persisted USD cost of running this HU. NULL
327
+ // until the orchestrator (Cost D) calls setStoryCost on HU close. UI
328
+ // renders "—" while NULL. Stored as REAL so the SQL aggregations in
329
+ // Cost E (/api/projects/:id/cost) can SUM directly without parsing.
330
+ try { db.exec('ALTER TABLE stories ADD COLUMN cost_usd REAL'); } catch { /* already migrated */ }
326
331
  try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
327
332
 
328
333
  // is_test (KJC-TSK-0371 — board polish #3): per-project flag that
@@ -499,6 +504,32 @@ export function updateStoryStatus(storyId, status) {
499
504
  return res.changes > 0;
500
505
  }
501
506
 
507
+ /**
508
+ * Persist the USD cost of a HU. Called by the orchestrator (Cost D) once
509
+ * the HU finishes — the value comes from `aggregateRunCost(events).totalUsd`
510
+ * (Cost B). `null` clears the cell so the UI renders "—" again. Other
511
+ * numeric inputs are coerced via `Number`; non-finite values reject with
512
+ * an explicit throw so callers don't silently NULL-out real cost rows.
513
+ *
514
+ * @param {string} storyId
515
+ * @param {number|null} costUsd
516
+ * @returns {boolean} true when the row existed and was updated
517
+ */
518
+ export function setStoryCost(storyId, costUsd) {
519
+ let value = null;
520
+ if (costUsd !== null && costUsd !== undefined) {
521
+ const n = Number(costUsd);
522
+ if (!Number.isFinite(n)) {
523
+ throw new Error(`setStoryCost: costUsd must be a finite number or null (got ${costUsd})`);
524
+ }
525
+ value = n;
526
+ }
527
+ const res = getDb()
528
+ .prepare('UPDATE stories SET cost_usd = ?, updated_at = ? WHERE id = ?')
529
+ .run(value, new Date().toISOString(), storyId);
530
+ return res.changes > 0;
531
+ }
532
+
502
533
  /**
503
534
  * List every distinct `plan_id` stamped on the stories of a given project.
504
535
  * Used by "mark plan ready" so the board can act on each plan of a project
@@ -598,6 +629,53 @@ export function getStoriesByProject(projectId) {
598
629
  `).all(projectId);
599
630
  }
600
631
 
632
+ /**
633
+ * Aggregate $ cost for a project (KJC-TSK-0516, Cost E).
634
+ * Sums stories.cost_usd, groups by plan_id ('unassigned' for NULL).
635
+ * Returns null if project does not exist; otherwise an object with
636
+ * totalUsd=0 and byPlan=[] when no stories carry cost_usd yet.
637
+ * @param {string} projectId
638
+ * @returns {{ totalUsd:number, byPlan:Array<{planId:string,totalUsd:number,huCount:number}>, unknownModelTokens:object, currency:string } | null}
639
+ */
640
+ export function getProjectCost(projectId) {
641
+ const project = getDb()
642
+ .prepare('SELECT id FROM projects WHERE id = ?')
643
+ .get(projectId);
644
+ if (!project) return null;
645
+
646
+ const rows = getDb()
647
+ .prepare(
648
+ `SELECT plan_id, cost_usd FROM stories
649
+ WHERE project_id = ? AND cost_usd IS NOT NULL`
650
+ )
651
+ .all(projectId);
652
+
653
+ const round4 = (n) => Math.round(n * 10000) / 10000;
654
+ const byPlanMap = new Map();
655
+ let totalUsd = 0;
656
+ for (const r of rows) {
657
+ const planId = r.plan_id || 'unassigned';
658
+ const cost = Number(r.cost_usd);
659
+ if (!Number.isFinite(cost)) continue;
660
+ totalUsd += cost;
661
+ const cur = byPlanMap.get(planId) || { planId, totalUsd: 0, huCount: 0 };
662
+ cur.totalUsd += cost;
663
+ cur.huCount += 1;
664
+ byPlanMap.set(planId, cur);
665
+ }
666
+
667
+ const byPlan = Array.from(byPlanMap.values())
668
+ .map((p) => ({ planId: p.planId, totalUsd: round4(p.totalUsd), huCount: p.huCount }))
669
+ .sort((a, b) => b.totalUsd - a.totalUsd);
670
+
671
+ return {
672
+ totalUsd: round4(totalUsd),
673
+ byPlan,
674
+ unknownModelTokens: { tokensIn: 0, tokensOut: 0, models: [] },
675
+ currency: 'USD',
676
+ };
677
+ }
678
+
601
679
  /**
602
680
  * Returns full story detail including context requests.
603
681
  * @param {string} storyId
@@ -60,6 +60,85 @@ export function shortTask(text, max = MAX_TASK_CHARS) {
60
60
  * created_at?: string|null }} session
61
61
  * @returns {{ title: string, subtitle: string, idChip: string }}
62
62
  */
63
+ /**
64
+ * Format a per-HU cost (USD) for the card badge. Cost F (KJC-TSK-0517).
65
+ *
66
+ * Two outputs in one call so the UI can render both at once:
67
+ * - `label`: 2-decimal "$X.XX" shown on the card (compact).
68
+ * - `tooltip`: 4-decimal "Estimated cost: $X.XXXX" shown on hover
69
+ * (matches the precision aggregated by Cost B/C/D, see
70
+ * `packages/core/src/cost/index.js`).
71
+ *
72
+ * Returns `null` for null/undefined/non-finite inputs so the caller
73
+ * skips the badge entirely (rendering "$0.00" for an unknown cost
74
+ * would be misleading — there is a real difference between "we know it
75
+ * was free" and "we haven't measured it yet").
76
+ *
77
+ * @param {number|null|undefined} costUsd
78
+ * @returns {{ label: string, tooltip: string } | null}
79
+ */
80
+ export function formatCost(costUsd) {
81
+ if (costUsd === null || costUsd === undefined) return null;
82
+ const n = Number(costUsd);
83
+ if (!Number.isFinite(n)) return null;
84
+ return {
85
+ label: `$${n.toFixed(2)}`,
86
+ tooltip: `Estimated cost: $${n.toFixed(4)}`,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Format the aggregated project-level cost chip rendered in the board
92
+ * header. Cost G (KJC-TSK-0518). Consumes the shape returned by
93
+ * `GET /api/projects/:id/cost` (Cost E):
94
+ * { totalUsd, byPlan: [{planId, totalUsd, huCount}, …],
95
+ * unknownModelTokens: { tokensIn, tokensOut, models }, currency }
96
+ *
97
+ * Returns `{ label, tooltip }` where:
98
+ * - `label` is the compact "Total: $X.XX" shown in the header.
99
+ * - `tooltip` is a multi-line string with full precision, the
100
+ * per-plan breakdown, and a note when some tokens used a model we
101
+ * don't price (they aren't included in the total — surfacing the
102
+ * skip is more honest than silently swallowing it).
103
+ *
104
+ * Returns `null` when there is nothing to show (no input, or no
105
+ * measured cost yet and no plans). The chip is then hidden — same
106
+ * reasoning as `formatCost`: "$0.00" with no data is misleading.
107
+ *
108
+ * @param {{totalUsd:number, byPlan?:Array, unknownModelTokens?:object}|null|undefined} cost
109
+ * @returns {{ label: string, tooltip: string } | null}
110
+ */
111
+ export function formatProjectCostSummary(cost) {
112
+ if (!cost || typeof cost !== "object") return null;
113
+ const total = Number(cost.totalUsd);
114
+ if (!Number.isFinite(total)) return null;
115
+ const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
116
+ if (total === 0 && byPlan.length === 0) return null;
117
+
118
+ const lines = [`Total: $${total.toFixed(4)}`];
119
+ if (byPlan.length > 0) {
120
+ lines.push("By plan:");
121
+ for (const p of byPlan) {
122
+ const planTotal = Number(p?.totalUsd);
123
+ if (!Number.isFinite(planTotal)) continue;
124
+ const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
125
+ const planLabel = p?.planId || "unassigned";
126
+ lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})`);
127
+ }
128
+ }
129
+ const unk = cost.unknownModelTokens;
130
+ if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
131
+ const unkTotal = unk.tokensIn + unk.tokensOut;
132
+ if (unkTotal > 0) {
133
+ lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
134
+ }
135
+ }
136
+ return {
137
+ label: `Total: $${total.toFixed(2)}`,
138
+ tooltip: lines.join("\n"),
139
+ };
140
+ }
141
+
63
142
  export function formatSessionLabel(session) {
64
143
  if (!session || !session.id) {
65
144
  return { title: "(no session)", subtitle: "", idChip: "" };
@@ -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;