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.
- package/README.md +12 -4
- package/package.json +6 -1
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/board-view.js +10 -1
- package/packages/hu-board/public/utils/formatters.js +41 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/db.js +78 -0
- package/packages/hu-board/src/format.js +79 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +16 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/git-hooks/post-merge +13 -2
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +21 -0
- package/src/cli/register-pipeline.js +3 -0
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/hu-sub-pipeline.js +52 -8
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/rag/vec-store.js +17 -253
- package/src/roles/audit-role.js +11 -1
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -123
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v3.
|
|
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.
|
|
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
|
|
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, '''))}');">✎</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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 '
|
|
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 '
|
|
26
|
-
import { validateBlockedByChange } from '
|
|
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('
|
|
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('
|
|
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 "
|
|
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 "
|
|
19
|
+
import { listActiveRuns as fsList } from "@karajan/core/run-registry";
|
|
20
20
|
|
|
21
21
|
const runs = new Map();
|
|
22
22
|
let reaperTimer = null;
|