karajan-code 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +14 -6
  2. package/docs/README.es.md +1 -1
  3. package/package.json +8 -2
  4. package/packages/hu-board/package.json +3 -1
  5. package/packages/hu-board/public/app.js +81 -4025
  6. package/packages/hu-board/public/index.html +21 -0
  7. package/packages/hu-board/public/styles.css +32 -0
  8. package/packages/hu-board/public/utils/api.js +139 -0
  9. package/packages/hu-board/public/utils/board-view.js +462 -0
  10. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  11. package/packages/hu-board/public/utils/config-editor.js +181 -0
  12. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  13. package/packages/hu-board/public/utils/formatters.js +273 -0
  14. package/packages/hu-board/public/utils/graph-view.js +216 -0
  15. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  16. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  17. package/packages/hu-board/public/utils/log-panel.js +222 -0
  18. package/packages/hu-board/public/utils/modals.js +290 -0
  19. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  20. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  21. package/packages/hu-board/public/utils/project-actions.js +143 -0
  22. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  23. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  24. package/packages/hu-board/public/utils/server-push.js +252 -0
  25. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  26. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  27. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  28. package/packages/hu-board/public/wiki.html +32 -0
  29. package/packages/hu-board/public/wiki.js +53 -0
  30. package/packages/hu-board/src/config-yaml.js +14 -0
  31. package/packages/hu-board/src/db.js +93 -2
  32. package/packages/hu-board/src/format.js +79 -0
  33. package/packages/hu-board/src/plan-mutations.js +4 -4
  34. package/packages/hu-board/src/routes/api.js +43 -10
  35. package/packages/hu-board/src/routes/rag.js +1 -1
  36. package/packages/hu-board/src/routes/wiki.js +43 -0
  37. package/packages/hu-board/src/run-tracker.js +1 -1
  38. package/packages/hu-board/src/server.js +4 -2
  39. package/packages/hu-board/src/sync.js +1 -1
  40. package/scripts/audit-test-diet.mjs +151 -0
  41. package/scripts/git-hooks/post-merge +13 -2
  42. package/src/audit/ai-slop-findings.js +102 -0
  43. package/src/audit/deterministic-summary.js +18 -0
  44. package/src/audit/harness-scorecard.js +52 -3
  45. package/src/audit/harness-section.js +6 -2
  46. package/src/brain/standby-scheduler.js +10 -92
  47. package/src/brain/standby-store.js +12 -168
  48. package/src/checks/ai-trash.js +34 -0
  49. package/src/checks/qmd.js +41 -0
  50. package/src/checks/runner.js +10 -0
  51. package/src/checks/squeezr.js +36 -0
  52. package/src/cli/register-meta.js +36 -2
  53. package/src/cli/register-pipeline.js +3 -0
  54. package/src/cli/register-plan.js +10 -1
  55. package/src/commands/audit.js +6 -4
  56. package/src/commands/board.js +34 -0
  57. package/src/commands/clean.js +62 -0
  58. package/src/commands/doctor.js +6 -0
  59. package/src/commands/init.js +73 -8
  60. package/src/commands/plan/generate.js +9 -3
  61. package/src/commands/qmd.js +86 -0
  62. package/src/commands/resume.js +6 -45
  63. package/src/commands/run.js +6 -50
  64. package/src/commands/sync.js +30 -2
  65. package/src/config/defaults.js +5 -0
  66. package/src/config/schema.js +2 -0
  67. package/src/git/hu-snapshot.js +10 -105
  68. package/src/orchestrator/config-init.js +1 -0
  69. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  70. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
  71. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  72. package/src/orchestrator/hu-sub-pipeline.js +52 -8
  73. package/src/orchestrator/stages/coder-stage.js +8 -2
  74. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  75. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  76. package/src/plan/plan-hu-ops.js +16 -338
  77. package/src/plan/plan-id.js +4 -45
  78. package/src/plan/plan-validation.js +4 -82
  79. package/src/prompts/tool-correctness-judge.js +31 -0
  80. package/src/rag/vec-store.js +17 -253
  81. package/src/review/git-stash-helper.js +104 -0
  82. package/src/review/tdd-discipline.js +90 -0
  83. package/src/roles/audit-role.js +11 -1
  84. package/src/roles/tool-correctness-judge-role.js +78 -0
  85. package/src/session/mutators.js +4 -0
  86. package/src/sync/apply-canvas-patch.js +55 -0
  87. package/src/utils/ai-trash.js +48 -0
  88. package/src/utils/atomic-write.js +6 -43
  89. package/src/utils/cli-ask-question.js +76 -0
  90. package/src/utils/doc-links.js +2 -0
  91. package/src/utils/os-detect.js +10 -0
  92. package/src/utils/paths.js +14 -111
  93. package/src/utils/port-check.js +6 -45
  94. package/src/utils/process.js +4 -188
  95. package/src/utils/qmd-collection.js +72 -0
  96. package/src/utils/qmd-detect.js +18 -0
  97. package/src/utils/qmd-install.js +37 -0
  98. package/src/utils/repo-cleaner.js +79 -0
  99. package/src/utils/run-registry.js +9 -116
  100. package/src/utils/shared-paths.js +12 -41
  101. package/src/utils/squeezr-detect.js +18 -0
  102. package/src/utils/squeezr-install.js +37 -0
  103. package/src/utils/tool-call-extractor.js +56 -0
  104. package/src/utils/vector-store-orphans.js +89 -0
@@ -0,0 +1,271 @@
1
+ // KJC-TSK-0501 step 8c/8 — Story edit form (inline editor for the HU modal).
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so the
4
+ // top-level `function` declarations are hoisted into the shared realm and
5
+ // `showStoryDetail` (story-detail-view.js) can call `renderStoryEditForm`
6
+ // when the user hits ✎ on a story.
7
+ //
8
+ // Globals consumed from earlier scripts / app.js:
9
+ // - esc() (app.js)
10
+ // - shortStoryId() (utils/formatters.js)
11
+ // - projectInitialsCache (mutable in app.js — assignment crosses
12
+ // scripts transparently)
13
+ // - showStoryDetail() (utils/story-detail-view.js)
14
+ // - showError() (utils/modals.js)
15
+ // - closeModal() (app.js → window.closeModal, used by
16
+ // inline onclick on the modal close button)
17
+ // - renderBoard() (app.js)
18
+
19
+ /**
20
+ * Swap the story modal into an inline edit form. Accepts the full story
21
+ * record that `showStoryDetail` just rendered so we don't re-fetch —
22
+ * the user's intent is "edit what I'm looking at".
23
+ *
24
+ * Cancel restores the read-only view via `showStoryDetail`; Save posts a
25
+ * PATCH and, on success, re-opens in read-only with the refreshed row.
26
+ *
27
+ * @param {object} story
28
+ */
29
+ function renderStoryEditForm(story) {
30
+ const content = document.getElementById('modal-content');
31
+ const initials = projectInitialsCache[story.project_id] || 'kj';
32
+ const shortId = shortStoryId(story, initials);
33
+
34
+ const rawAc = story.acceptance_criteria ? JSON.parse(story.acceptance_criteria) : [];
35
+ // Represent AC as text: plain strings go verbatim, Gherkin objects
36
+ // collapse to "Given … | When … | Then …". On save we parse back —
37
+ // if the line matches the pattern we treat it as Gherkin, otherwise
38
+ // a free-form string.
39
+ const acInitial = rawAc
40
+ .map((c) => typeof c === 'string' ? c : (c.given ? `Given ${c.given} | When ${c.when} | Then ${c.then}` : JSON.stringify(c)))
41
+ .join('\n');
42
+
43
+ // Tests-first editor: one block per test with a type selector
44
+ // (shell | gherkin), the content textarea, optional file path, and
45
+ // a remove button. Save collects them into the structured v2.7.5
46
+ // array shape. Plain strings in the existing data are treated as
47
+ // legacy shell tests and auto-upgraded to the structured form.
48
+ const rawTests = story.acceptance_tests ? JSON.parse(story.acceptance_tests) : [];
49
+ const testsInitial = rawTests.map((t) => {
50
+ if (typeof t === 'string') return { type: 'shell', content: t, file: '' };
51
+ if (t && typeof t === 'object') {
52
+ return {
53
+ type: t.type === 'gherkin' ? 'gherkin' : 'shell',
54
+ content: typeof t.content === 'string' ? t.content : JSON.stringify(t),
55
+ file: typeof t.file === 'string' ? t.file : '',
56
+ };
57
+ }
58
+ return { type: 'shell', content: String(t ?? ''), file: '' };
59
+ });
60
+
61
+ const scopeInitial = story.original_text || story.certified_want || '';
62
+ const TASK_TYPES = ['sw', 'infra', 'doc', 'add-tests', 'refactor'];
63
+
64
+ content.innerHTML = `
65
+ <div class="modal__header">
66
+ <div>
67
+ <div class="modal__title" title="${esc(story.id)}">${esc(shortId)} <span style="font-size:0.75rem;color:var(--text-muted);font-weight:normal">(editing)</span></div>
68
+ <div class="modal__subtitle" style="font-size:0.75rem;color:var(--text-muted);font-family:monospace;margin-top:2px">${esc(story.id)}</div>
69
+ </div>
70
+ <button class="modal__close" onclick="closeModal()">&times;</button>
71
+ </div>
72
+
73
+ <form id="hu-edit-form" onsubmit="return false" style="display:flex;flex-direction:column;gap:14px;padding:8px 0">
74
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
75
+ <span style="color:var(--text-muted)">Title</span>
76
+ <input type="text" id="edit-title" value="${esc(story.title || '')}"
77
+ style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.95rem"
78
+ maxlength="200" required>
79
+ </label>
80
+
81
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
82
+ <span style="color:var(--text-muted)">Scope</span>
83
+ <textarea id="edit-scope" rows="4"
84
+ style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:inherit;font-size:0.9rem;line-height:1.5;resize:vertical">${esc(scopeInitial)}</textarea>
85
+ </label>
86
+
87
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
88
+ <span style="color:var(--text-muted)">Task type</span>
89
+ <select id="edit-task-type"
90
+ style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
91
+ ${TASK_TYPES.map((t) => `<option value="${t}">${t}</option>`).join('')}
92
+ </select>
93
+ </label>
94
+
95
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
96
+ <span style="color:var(--text-muted)">
97
+ Acceptance criteria — one per line.
98
+ Gherkin: <code>Given X | When Y | Then Z</code>
99
+ </span>
100
+ <textarea id="edit-ac" rows="6"
101
+ style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.85rem;line-height:1.5;resize:vertical">${esc(acInitial)}</textarea>
102
+ </label>
103
+
104
+ <fieldset style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;margin:0">
105
+ <legend style="color:var(--text-muted);font-size:0.85rem;padding:0 6px">
106
+ Acceptance tests — the contract the coder must satisfy
107
+ </legend>
108
+ <div id="edit-tests-list" style="display:flex;flex-direction:column;gap:8px"></div>
109
+ <button type="button" id="edit-tests-add"
110
+ style="margin-top:8px;padding:4px 10px;font-size:0.8rem;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer">
111
+ + Add test
112
+ </button>
113
+ </fieldset>
114
+
115
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;padding-top:12px;border-top:1px solid var(--border)">
116
+ <button type="button" id="edit-cancel" class="control-btn"
117
+ style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
118
+ Cancel
119
+ </button>
120
+ <button type="button" id="edit-save" class="control-btn"
121
+ style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
122
+ Save
123
+ </button>
124
+ </div>
125
+ </form>
126
+ `;
127
+
128
+ // Tests editor state + renderers. Kept on the function scope (not
129
+ // on window) so reopening the form gives a fresh copy.
130
+ let testRows = [...testsInitial];
131
+ const listEl = document.getElementById('edit-tests-list');
132
+
133
+ function renderTestsEditor() {
134
+ listEl.innerHTML = testRows.map((t, i) => `
135
+ <div class="edit-test-row" data-idx="${i}"
136
+ style="display:grid;grid-template-columns:auto 1fr auto;gap:6px;align-items:start;padding:8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-primary)">
137
+ <select data-field="type"
138
+ style="padding:4px 6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-size:0.8rem">
139
+ <option value="shell"${t.type === 'shell' ? ' selected' : ''}>shell</option>
140
+ <option value="gherkin"${t.type === 'gherkin' ? ' selected' : ''}>gherkin</option>
141
+ </select>
142
+ <div style="display:flex;flex-direction:column;gap:4px">
143
+ <textarea data-field="content" rows="${t.type === 'gherkin' ? 3 : 2}"
144
+ placeholder="${t.type === 'gherkin' ? 'Given …\\nWhen …\\nThen …' : 'npx vitest run test/foo.test.js'}"
145
+ style="padding:6px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.82rem;line-height:1.4;resize:vertical">${esc(t.content)}</textarea>
146
+ <input type="text" data-field="file" placeholder="Optional: file path (e.g. tests/login.test.ts)" value="${esc(t.file || '')}"
147
+ style="padding:4px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.75rem">
148
+ </div>
149
+ <button type="button" data-field="remove" title="Remove this test"
150
+ style="align-self:start;padding:4px 8px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:var(--radius-sm);cursor:pointer;font-size:0.8rem">✕</button>
151
+ </div>
152
+ `).join('');
153
+
154
+ // Bind the edit events. We re-bind on every render — cheap, and
155
+ // keeps the state → DOM mapping simple.
156
+ listEl.querySelectorAll('.edit-test-row').forEach((row) => {
157
+ const idx = Number(row.dataset.idx);
158
+ row.querySelector('[data-field="type"]').addEventListener('change', (e) => {
159
+ testRows[idx].type = e.target.value;
160
+ renderTestsEditor(); // re-render so placeholder updates
161
+ });
162
+ row.querySelector('[data-field="content"]').addEventListener('input', (e) => {
163
+ testRows[idx].content = e.target.value;
164
+ });
165
+ row.querySelector('[data-field="file"]').addEventListener('input', (e) => {
166
+ testRows[idx].file = e.target.value;
167
+ });
168
+ row.querySelector('[data-field="remove"]').addEventListener('click', () => {
169
+ testRows.splice(idx, 1);
170
+ renderTestsEditor();
171
+ });
172
+ });
173
+ }
174
+
175
+ renderTestsEditor();
176
+
177
+ document.getElementById('edit-tests-add').addEventListener('click', () => {
178
+ testRows.push({ type: 'shell', content: '', file: '' });
179
+ renderTestsEditor();
180
+ });
181
+
182
+ document.getElementById('edit-cancel').addEventListener('click', () => showStoryDetail(story.id));
183
+ document.getElementById('edit-save').addEventListener('click', () => saveStoryEdits(story, () => testRows));
184
+ }
185
+
186
+ /**
187
+ * Collect the form values, compute the diff against the story as it
188
+ * was on modal open, and PATCH only the changed fields. Empty diff is
189
+ * a no-op (close out of edit mode without an API call).
190
+ * @param {object} story
191
+ */
192
+ async function saveStoryEdits(story, getTestRows) {
193
+ const title = document.getElementById('edit-title').value.trim();
194
+ const scope = document.getElementById('edit-scope').value;
195
+ const taskType = document.getElementById('edit-task-type').value;
196
+ const acRaw = document.getElementById('edit-ac').value;
197
+
198
+ if (!title) {
199
+ await showError('Title cannot be empty.', { title: 'Invalid input' });
200
+ return;
201
+ }
202
+
203
+ // Parse AC: one per line; a line with "Given X | When Y | Then Z"
204
+ // (case-insensitive) becomes Gherkin; everything else stays a string.
205
+ // Blank lines are dropped.
206
+ const acceptance_criteria = acRaw.split('\n')
207
+ .map((line) => line.trim())
208
+ .filter(Boolean)
209
+ .map((line) => {
210
+ const m = /^Given\s+(.*?)\s*\|\s*When\s+(.*?)\s*\|\s*Then\s+(.*)$/i.exec(line);
211
+ if (m) return { given: m[1].trim(), when: m[2].trim(), then: m[3].trim() };
212
+ return line;
213
+ });
214
+
215
+ // Collect the structured acceptance_tests from the editor state,
216
+ // dropping rows the user left empty.
217
+ const testRows = typeof getTestRows === 'function' ? getTestRows() : [];
218
+ const acceptance_tests = testRows
219
+ .map((t) => ({
220
+ type: t.type === 'gherkin' ? 'gherkin' : 'shell',
221
+ content: (t.content || '').trim(),
222
+ file: (t.file || '').trim(),
223
+ }))
224
+ .filter((t) => t.content.length > 0)
225
+ .map((t) => (t.file ? t : { type: t.type, content: t.content }));
226
+
227
+ // Diff against the original so we don't send untouched fields and
228
+ // let the server's COALESCE keep existing values.
229
+ const patch = {};
230
+ const originalScope = story.original_text || story.certified_want || '';
231
+ if (title !== (story.title || '')) patch.title = title;
232
+ if (scope !== originalScope) patch.scope = scope;
233
+ if (taskType && taskType !== 'sw') patch.task_type = taskType;
234
+ const prevAcStr = story.acceptance_criteria || '[]';
235
+ const nextAcStr = JSON.stringify(acceptance_criteria);
236
+ if (nextAcStr !== prevAcStr) patch.acceptance_criteria = acceptance_criteria;
237
+ const prevTestsStr = story.acceptance_tests || '[]';
238
+ const nextTestsStr = JSON.stringify(acceptance_tests);
239
+ if (nextTestsStr !== prevTestsStr) patch.acceptance_tests = acceptance_tests;
240
+
241
+ if (Object.keys(patch).length === 0) {
242
+ showStoryDetail(story.id);
243
+ return;
244
+ }
245
+
246
+ try {
247
+ const res = await fetch(`/api/stories/${encodeURIComponent(story.id)}`, {
248
+ method: 'PATCH',
249
+ headers: { 'Content-Type': 'application/json' },
250
+ body: JSON.stringify(patch),
251
+ });
252
+ if (!res.ok) {
253
+ if (res.status === 404) {
254
+ await showError(
255
+ 'The board server does not recognise the extended PATCH endpoint.\n\n'
256
+ + 'Restart it to pick up v2.7.5+:\n\n'
257
+ + ' kj board stop && kj board start',
258
+ { title: 'Board out of date' }
259
+ );
260
+ return;
261
+ }
262
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
263
+ await showError(msg, { title: 'Could not save HU' });
264
+ return;
265
+ }
266
+ await renderBoard(); // card shows new title/counts
267
+ await showStoryDetail(story.id); // reopen modal with fresh data
268
+ } catch (err) {
269
+ await showError(err.message, { title: 'Could not save HU' });
270
+ }
271
+ }
@@ -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);
@@ -349,11 +349,19 @@ function getDeep(obj, path) {
349
349
  return cur;
350
350
  }
351
351
 
352
+ // KJC-BUG-0076: never walk into Object.prototype via crafted YAML keys
353
+ // (`__proto__.x`, `constructor.prototype.y`, `prototype.z`). Silent skip
354
+ // over a poisoned segment — the rest of the path is untouched.
355
+ const UNSAFE_KEY_RE = /^(?:__proto__|constructor|prototype)$/;
356
+
352
357
  function setDeep(obj, path, value) {
358
+ if (typeof path !== 'string') return;
353
359
  const parts = path.split('.');
354
360
  const last = parts.pop();
361
+ if (UNSAFE_KEY_RE.test(last)) return;
355
362
  let cur = obj;
356
363
  for (const p of parts) {
364
+ if (UNSAFE_KEY_RE.test(p)) return;
357
365
  if (cur[p] == null || typeof cur[p] !== 'object') cur[p] = {};
358
366
  cur = cur[p];
359
367
  }
@@ -361,10 +369,13 @@ function setDeep(obj, path, value) {
361
369
  }
362
370
 
363
371
  function deleteDeep(obj, path) {
372
+ if (typeof path !== 'string') return;
364
373
  const parts = path.split('.');
365
374
  const last = parts.pop();
375
+ if (UNSAFE_KEY_RE.test(last)) return;
366
376
  let cur = obj;
367
377
  for (const p of parts) {
378
+ if (UNSAFE_KEY_RE.test(p)) return;
368
379
  if (cur == null || typeof cur !== 'object') return;
369
380
  cur = cur[p];
370
381
  }
@@ -516,3 +527,6 @@ export function writeConfigPatch(patch, { scope = 'global' } = {}) {
516
527
  renameSync(tmpFile, p);
517
528
  return { path: p, scope, written: true, applied, errors: [] };
518
529
  }
530
+
531
+ // Exposed for prototype-pollution regression tests (KJC-BUG-0076).
532
+ export const __pollutionInternals = { setDeep, deleteDeep };
@@ -1,6 +1,6 @@
1
1
  import Database from 'better-sqlite3';
2
- import { join } from 'node:path';
3
- import { mkdirSync, renameSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { mkdirSync, renameSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
 
6
6
  /** @type {import('better-sqlite3').Database | null} */
@@ -23,6 +23,19 @@ function vitestTmpKjHome() {
23
23
  `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
24
24
  '.karajan'
25
25
  );
26
+ // KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
27
+ // dir per fork × run. We rm the *parent* (the `karajan-vitest-<pid>-<rand>`
28
+ // wrapper), not the `.karajan` segment, so the whole tmp tree goes.
29
+ // Only fires on graceful exit; SIGKILL leaks are caught by the
30
+ // global-setup mtime purge at the start of the next run.
31
+ const parent = dirname(_vitestKjHome);
32
+ process.on('exit', () => {
33
+ try {
34
+ rmSync(parent, { recursive: true, force: true });
35
+ } catch {
36
+ // best-effort: never crash a test process on cleanup failure
37
+ }
38
+ });
26
39
  return _vitestKjHome;
27
40
  }
28
41
 
@@ -310,6 +323,11 @@ export function initDb() {
310
323
  // unconditionally so solo devs who later promote a plan don't lose
311
324
  // values they typed pre-share.
312
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 */ }
313
331
  try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
314
332
 
315
333
  // is_test (KJC-TSK-0371 — board polish #3): per-project flag that
@@ -486,6 +504,32 @@ export function updateStoryStatus(storyId, status) {
486
504
  return res.changes > 0;
487
505
  }
488
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
+
489
533
  /**
490
534
  * List every distinct `plan_id` stamped on the stories of a given project.
491
535
  * Used by "mark plan ready" so the board can act on each plan of a project
@@ -585,6 +629,53 @@ export function getStoriesByProject(projectId) {
585
629
  `).all(projectId);
586
630
  }
587
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
+
588
679
  /**
589
680
  * Returns full story detail including context requests.
590
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