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
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.3.0 released** — Cross-provider cache observability (epic KJC-PCS-0056 / Phase 0 closed). Every `kj run` now measures provider-level prompt-cache hits end-to-end across Anthropic, OpenAI/Codex, Gemini, aider and opencode — same `cached_tokens` field flows from agent → BudgetTracker → `summary.md` → `board.db` → HU Board badge `🎯 N%` → telemetry `pipeline_complete`. Real data: Claude cold→hot 47.2% → 94.3% cache_pct (**76.4% cost savings on a single HU**), Gemini 87.9% → 96.8%. Null-safe: badge hides when unmeasured. Drop-in upgrade from v3.2.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.3.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,14 @@ 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;
43
+ // Φ0-G (KJC-TSK-0525): aggregated cache-hit badge at the project
44
+ // header, rendered alongside the project cost chip.
45
+ const cacheSummary = projectCost ? formatCacheRatio(projectCost.cachedTokens, projectCost.tokensIn) : null;
39
46
 
40
47
  // Pre-resolve project initials + name for every distinct project_id in
41
48
  // the fetched stories so `renderStoryCard` is synchronous and the header
@@ -96,6 +103,8 @@ async function renderBoard() {
96
103
  onclick="event.stopPropagation(); window.renameProjectModal('${esc(selectedProject)}', '${esc(projectDisplayName.replace(/'/g, '&#39;'))}');">✎</button>
97
104
  ` : ''}
98
105
  <span class="section-header__count">${stories.length} stories</span>
106
+ ${costSummary ? `<span class="section-header__cost" title="${esc(costSummary.tooltip)}">💵 ${esc(costSummary.label)}</span>` : ''}
107
+ ${cacheSummary ? `<span class="section-header__cache" title="${esc(cacheSummary.tooltip)}">${esc(cacheSummary.label)}</span>` : ''}
99
108
  ${isRunning ? `
100
109
  <button id="running-badge-btn" class="section-header__badge"
101
110
  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 +381,17 @@ function renderStoryCard(story) {
372
381
  ${story.quality_total}/60 ${qualityBar(story.quality_total)}
373
382
  </span>
374
383
  ` : ''}
384
+ ${(() => {
385
+ const c = formatCost(story.cost_usd);
386
+ return c ? `<span class="story-card__cost" title="${esc(c.tooltip)}">💵 ${esc(c.label)}</span>` : '';
387
+ })()}
388
+ ${(() => {
389
+ // Φ0-G (KJC-TSK-0525): per-HU cache hit badge. Rendered next to
390
+ // the cost so users can correlate "this run cost $X and 73% of
391
+ // input was served from cache" in one glance.
392
+ const r = formatCacheRatio(story.cached_tokens, story.tokens_in);
393
+ return r ? `<span class="story-card__cache" title="${esc(r.tooltip)}">${esc(r.label)}</span>` : '';
394
+ })()}
375
395
  </div>
376
396
  ${missingTestContract ? `
377
397
  <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,74 @@ 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
+ // Φ0-G (KJC-TSK-0525): mirror of formatCacheRatio in src/format.js.
59
+ // Kept duplicated because this file is a classic script loaded into
60
+ // the browser, while src/format.js uses ES modules and is consumed by
61
+ // Node tests. See KJC-TSK-0501 header notes above.
62
+ function formatCacheRatio(cachedTokens, tokensIn) {
63
+ if (cachedTokens === null || cachedTokens === undefined) return null;
64
+ if (tokensIn === null || tokensIn === undefined) return null;
65
+ const c = Number(cachedTokens);
66
+ const t = Number(tokensIn);
67
+ if (!Number.isFinite(c) || !Number.isFinite(t)) return null;
68
+ if (t <= 0 || c < 0) return null;
69
+ const pct = Math.round((c / t) * 1000) / 10;
70
+ const num = c.toLocaleString("en-US");
71
+ const den = t.toLocaleString("en-US");
72
+ return {
73
+ label: `🎯 ${pct}%`,
74
+ tooltip: `Cache hits: ${num} / ${den} input tokens (${pct}%)`,
75
+ };
76
+ }
77
+
78
+ function formatProjectCostSummary(cost) {
79
+ if (!cost || typeof cost !== "object") return null;
80
+ const total = Number(cost.totalUsd);
81
+ if (!Number.isFinite(total)) return null;
82
+ const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
83
+ if (total === 0 && byPlan.length === 0) return null;
84
+
85
+ const lines = [`Total: $${total.toFixed(4)}`];
86
+ // Φ0-G (KJC-TSK-0525): aggregate cache hits line + per-plan suffix.
87
+ const projCache = formatCacheRatio(cost.cachedTokens, cost.tokensIn);
88
+ if (projCache) lines.push(`Cache: ${projCache.tooltip.split(": ")[1]}`);
89
+ if (byPlan.length > 0) {
90
+ lines.push("By plan:");
91
+ for (const p of byPlan) {
92
+ const planTotal = Number(p?.totalUsd);
93
+ if (!Number.isFinite(planTotal)) continue;
94
+ const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
95
+ const planLabel = p?.planId || "unassigned";
96
+ const cachePct = p?.cachedRatioPct;
97
+ const cacheSuffix = (cachePct !== null && cachePct !== undefined && Number.isFinite(Number(cachePct)))
98
+ ? ` 🎯 ${cachePct}%`
99
+ : "";
100
+ lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})${cacheSuffix}`);
101
+ }
102
+ }
103
+ const unk = cost.unknownModelTokens;
104
+ if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
105
+ const unkTotal = unk.tokensIn + unk.tokensOut;
106
+ if (unkTotal > 0) {
107
+ lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
108
+ }
109
+ }
110
+ return {
111
+ label: `Total: $${total.toFixed(2)}`,
112
+ tooltip: lines.join("\n"),
113
+ };
114
+ }
115
+
48
116
  function humaniseProjectName(id) {
49
117
  if (!id || typeof id !== 'string') return id || '';
50
118
  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,18 @@ 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 */ }
331
+ // KJC-TSK-0525 (Φ0-G): persisted cached/input tokens per HU so the
332
+ // board can render the "🎯 N% cached" badge without re-parsing the
333
+ // session summary. NULL = unmeasured (older runs, cold runs without
334
+ // cache support). INTEGER because providers report token counts as
335
+ // ints. Ratio is computed at read time as cached_tokens / tokens_in.
336
+ try { db.exec('ALTER TABLE stories ADD COLUMN cached_tokens INTEGER'); } catch { /* already migrated */ }
337
+ try { db.exec('ALTER TABLE stories ADD COLUMN tokens_in INTEGER'); } catch { /* already migrated */ }
326
338
  try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
327
339
 
328
340
  // is_test (KJC-TSK-0371 — board polish #3): per-project flag that
@@ -499,6 +511,61 @@ export function updateStoryStatus(storyId, status) {
499
511
  return res.changes > 0;
500
512
  }
501
513
 
514
+ /**
515
+ * Persist the USD cost of a HU. Called by the orchestrator (Cost D) once
516
+ * the HU finishes — the value comes from `aggregateRunCost(events).totalUsd`
517
+ * (Cost B). `null` clears the cell so the UI renders "—" again. Other
518
+ * numeric inputs are coerced via `Number`; non-finite values reject with
519
+ * an explicit throw so callers don't silently NULL-out real cost rows.
520
+ *
521
+ * @param {string} storyId
522
+ * @param {number|null} costUsd
523
+ * @returns {boolean} true when the row existed and was updated
524
+ */
525
+ export function setStoryCost(storyId, costUsd) {
526
+ let value = null;
527
+ if (costUsd !== null && costUsd !== undefined) {
528
+ const n = Number(costUsd);
529
+ if (!Number.isFinite(n)) {
530
+ throw new Error(`setStoryCost: costUsd must be a finite number or null (got ${costUsd})`);
531
+ }
532
+ value = n;
533
+ }
534
+ const res = getDb()
535
+ .prepare('UPDATE stories SET cost_usd = ?, updated_at = ? WHERE id = ?')
536
+ .run(value, new Date().toISOString(), storyId);
537
+ return res.changes > 0;
538
+ }
539
+
540
+ /**
541
+ * Persist cached/input tokens for a HU (KJC-TSK-0525, Φ0-G). Called by
542
+ * the orchestrator alongside `setStoryCost` once the HU finishes. Both
543
+ * values come from `BudgetTracker.since(huCursor)` (Φ0-E). Pass `null`
544
+ * to clear either cell. Non-finite numbers throw so callers don't
545
+ * silently null-out real telemetry.
546
+ *
547
+ * @param {string} storyId
548
+ * @param {number|null} cachedTokens
549
+ * @param {number|null} tokensIn
550
+ * @returns {boolean} true when the row existed and was updated
551
+ */
552
+ export function setStoryCachedTokens(storyId, cachedTokens, tokensIn) {
553
+ const coerce = (v, name) => {
554
+ if (v === null || v === undefined) return null;
555
+ const n = Number(v);
556
+ if (!Number.isFinite(n) || n < 0) {
557
+ throw new Error(`setStoryCachedTokens: ${name} must be a non-negative finite number or null (got ${v})`);
558
+ }
559
+ return Math.round(n);
560
+ };
561
+ const cached = coerce(cachedTokens, 'cachedTokens');
562
+ const tin = coerce(tokensIn, 'tokensIn');
563
+ const res = getDb()
564
+ .prepare('UPDATE stories SET cached_tokens = ?, tokens_in = ?, updated_at = ? WHERE id = ?')
565
+ .run(cached, tin, new Date().toISOString(), storyId);
566
+ return res.changes > 0;
567
+ }
568
+
502
569
  /**
503
570
  * List every distinct `plan_id` stamped on the stories of a given project.
504
571
  * Used by "mark plan ready" so the board can act on each plan of a project
@@ -598,6 +665,76 @@ export function getStoriesByProject(projectId) {
598
665
  `).all(projectId);
599
666
  }
600
667
 
668
+ /**
669
+ * Aggregate $ cost for a project (KJC-TSK-0516, Cost E).
670
+ * Sums stories.cost_usd, groups by plan_id ('unassigned' for NULL).
671
+ * Returns null if project does not exist; otherwise an object with
672
+ * totalUsd=0 and byPlan=[] when no stories carry cost_usd yet.
673
+ * @param {string} projectId
674
+ * @returns {{ totalUsd:number, byPlan:Array<{planId:string,totalUsd:number,huCount:number}>, unknownModelTokens:object, currency:string } | null}
675
+ */
676
+ export function getProjectCost(projectId) {
677
+ const project = getDb()
678
+ .prepare('SELECT id FROM projects WHERE id = ?')
679
+ .get(projectId);
680
+ if (!project) return null;
681
+
682
+ // Φ0-G: cached_tokens / tokens_in are nullable independently of
683
+ // cost_usd, so we read all three columns but keep cost as the
684
+ // primary filter (a row with only cache telemetry but no $ should
685
+ // not appear in the per-plan totals — cost rules the byPlan list).
686
+ const rows = getDb()
687
+ .prepare(
688
+ `SELECT plan_id, cost_usd, cached_tokens, tokens_in FROM stories
689
+ WHERE project_id = ? AND cost_usd IS NOT NULL`
690
+ )
691
+ .all(projectId);
692
+
693
+ const round4 = (n) => Math.round(n * 10000) / 10000;
694
+ const byPlanMap = new Map();
695
+ let totalUsd = 0;
696
+ let totalCached = 0;
697
+ let totalTokensIn = 0;
698
+ for (const r of rows) {
699
+ const planId = r.plan_id || 'unassigned';
700
+ const cost = Number(r.cost_usd);
701
+ if (!Number.isFinite(cost)) continue;
702
+ totalUsd += cost;
703
+ const cached = Number(r.cached_tokens);
704
+ const tin = Number(r.tokens_in);
705
+ if (Number.isFinite(cached) && cached >= 0) totalCached += cached;
706
+ if (Number.isFinite(tin) && tin >= 0) totalTokensIn += tin;
707
+ const cur = byPlanMap.get(planId) || { planId, totalUsd: 0, huCount: 0, cachedTokens: 0, tokensIn: 0 };
708
+ cur.totalUsd += cost;
709
+ cur.huCount += 1;
710
+ if (Number.isFinite(cached) && cached >= 0) cur.cachedTokens += cached;
711
+ if (Number.isFinite(tin) && tin >= 0) cur.tokensIn += tin;
712
+ byPlanMap.set(planId, cur);
713
+ }
714
+
715
+ const ratio = (cached, tin) => tin > 0 ? Math.round((cached / tin) * 1000) / 10 : null;
716
+ const byPlan = Array.from(byPlanMap.values())
717
+ .map((p) => ({
718
+ planId: p.planId,
719
+ totalUsd: round4(p.totalUsd),
720
+ huCount: p.huCount,
721
+ cachedTokens: p.cachedTokens,
722
+ tokensIn: p.tokensIn,
723
+ cachedRatioPct: ratio(p.cachedTokens, p.tokensIn),
724
+ }))
725
+ .sort((a, b) => b.totalUsd - a.totalUsd);
726
+
727
+ return {
728
+ totalUsd: round4(totalUsd),
729
+ byPlan,
730
+ cachedTokens: totalCached,
731
+ tokensIn: totalTokensIn,
732
+ cachedRatioPct: ratio(totalCached, totalTokensIn),
733
+ unknownModelTokens: { tokensIn: 0, tokensOut: 0, models: [] },
734
+ currency: 'USD',
735
+ };
736
+ }
737
+
601
738
  /**
602
739
  * Returns full story detail including context requests.
603
740
  * @param {string} storyId
@@ -60,6 +60,130 @@ 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
+ * Φ0-G (KJC-TSK-0525): per-HU and per-plan cached-tokens badge.
92
+ *
93
+ * Returns `{ label, tooltip }` for "🎯 23%" with a tooltip showing the
94
+ * exact numerator/denominator. Same "don't render 0% when there is no
95
+ * data" stance as formatCost — returns null when:
96
+ * - either arg is null/undefined/non-finite,
97
+ * - tokens_in is 0 (no input → ratio undefined, not zero),
98
+ * - cached is negative (would only happen from corrupted telemetry).
99
+ *
100
+ * The badge intentionally shows 1 decimal to keep narrow runs (e.g.
101
+ * a 73-token cache out of 1 200) readable without rounding to 0%.
102
+ *
103
+ * @param {number|null|undefined} cachedTokens
104
+ * @param {number|null|undefined} tokensIn
105
+ * @returns {{ label: string, tooltip: string } | null}
106
+ */
107
+ export function formatCacheRatio(cachedTokens, tokensIn) {
108
+ // Reject null/undefined explicitly — Number(null) coerces to 0,
109
+ // which would otherwise render an honest-looking "🎯 0%" badge for
110
+ // runs where we simply have no telemetry. Same stance as formatCost.
111
+ if (cachedTokens === null || cachedTokens === undefined) return null;
112
+ if (tokensIn === null || tokensIn === undefined) return null;
113
+ const c = Number(cachedTokens);
114
+ const t = Number(tokensIn);
115
+ if (!Number.isFinite(c) || !Number.isFinite(t)) return null;
116
+ if (t <= 0 || c < 0) return null;
117
+ const pct = Math.round((c / t) * 1000) / 10;
118
+ const num = c.toLocaleString("en-US");
119
+ const den = t.toLocaleString("en-US");
120
+ return {
121
+ label: `🎯 ${pct}%`,
122
+ tooltip: `Cache hits: ${num} / ${den} input tokens (${pct}%)`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Format the aggregated project-level cost chip rendered in the board
128
+ * header. Cost G (KJC-TSK-0518). Consumes the shape returned by
129
+ * `GET /api/projects/:id/cost` (Cost E):
130
+ * { totalUsd, byPlan: [{planId, totalUsd, huCount}, …],
131
+ * unknownModelTokens: { tokensIn, tokensOut, models }, currency }
132
+ *
133
+ * Returns `{ label, tooltip }` where:
134
+ * - `label` is the compact "Total: $X.XX" shown in the header.
135
+ * - `tooltip` is a multi-line string with full precision, the
136
+ * per-plan breakdown, and a note when some tokens used a model we
137
+ * don't price (they aren't included in the total — surfacing the
138
+ * skip is more honest than silently swallowing it).
139
+ *
140
+ * Returns `null` when there is nothing to show (no input, or no
141
+ * measured cost yet and no plans). The chip is then hidden — same
142
+ * reasoning as `formatCost`: "$0.00" with no data is misleading.
143
+ *
144
+ * @param {{totalUsd:number, byPlan?:Array, unknownModelTokens?:object}|null|undefined} cost
145
+ * @returns {{ label: string, tooltip: string } | null}
146
+ */
147
+ export function formatProjectCostSummary(cost) {
148
+ if (!cost || typeof cost !== "object") return null;
149
+ const total = Number(cost.totalUsd);
150
+ if (!Number.isFinite(total)) return null;
151
+ const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
152
+ if (total === 0 && byPlan.length === 0) return null;
153
+
154
+ const lines = [`Total: $${total.toFixed(4)}`];
155
+ // Φ0-G (KJC-TSK-0525): when the API includes project-level cache
156
+ // counters, show the aggregate cached% line right under the total
157
+ // so users can see at a glance how much was served from cache.
158
+ const projCache = formatCacheRatio(cost.cachedTokens, cost.tokensIn);
159
+ if (projCache) lines.push(`Cache: ${projCache.tooltip.split(": ")[1]}`);
160
+ if (byPlan.length > 0) {
161
+ lines.push("By plan:");
162
+ for (const p of byPlan) {
163
+ const planTotal = Number(p?.totalUsd);
164
+ if (!Number.isFinite(planTotal)) continue;
165
+ const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
166
+ const planLabel = p?.planId || "unassigned";
167
+ const cachePct = p?.cachedRatioPct;
168
+ const cacheSuffix = (cachePct !== null && cachePct !== undefined && Number.isFinite(Number(cachePct)))
169
+ ? ` 🎯 ${cachePct}%`
170
+ : "";
171
+ lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})${cacheSuffix}`);
172
+ }
173
+ }
174
+ const unk = cost.unknownModelTokens;
175
+ if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
176
+ const unkTotal = unk.tokensIn + unk.tokensOut;
177
+ if (unkTotal > 0) {
178
+ lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
179
+ }
180
+ }
181
+ return {
182
+ label: `Total: $${total.toFixed(2)}`,
183
+ tooltip: lines.join("\n"),
184
+ };
185
+ }
186
+
63
187
  export function formatSessionLabel(session) {
64
188
  if (!session || !session.id) {
65
189
  return { title: "(no session)", subtitle: "", idChip: "" };