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,462 @@
1
+ // KJC-TSK-0501 step 5/8 — Board view (kanban, columns, story card, result helpers).
2
+ //
3
+ // Classic script. Loaded by index.html after utils/sessions-view.js, before
4
+ // app.js. Function declarations hoist to the global scope so app.js (and
5
+ // inline onclick handlers) can call them transparently.
6
+ //
7
+ // Globals consumed from app.js / earlier scripts:
8
+ // - api(), esc(), renderEmptyState(), renderProjectPicker(),
9
+ // renderPreflightPanel(), renderPlanRollup(), confirmRunWithPreflight(),
10
+ // runProject(), openGenericLogPanel(), humaniseProjectName(),
11
+ // resolveProjectMeta(), shortStoryId(), truncate(), scoreClass(),
12
+ // qualityBar(), timeAgo(), showError(), showConfirm(),
13
+ // showStoryDetail(), showOutcomeModal(), runSingleHuFromCard(),
14
+ // renameProjectModal()
15
+ // - mutable: selectedProject, lastOpenedLog, projectNameCache,
16
+ // projectInitialsCache
17
+ // All lookups are runtime-bound; load order only needs board-view.js to live
18
+ // in the same realm before app.js executes its bootstrap.
19
+
20
+ /**
21
+ * Renders the kanban board view.
22
+ */
23
+ async function renderBoard() {
24
+ const app = document.getElementById('app');
25
+ app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading board...</p></div>';
26
+
27
+ try {
28
+ // No project selected → render the project picker instead of
29
+ // mixing every project's HUs in one kanban. The user's
30
+ // complaint: "if no project is selected, show me the list of
31
+ // projects". A mixed kanban hides which HU belongs to which
32
+ // project and is borderline useless on a multi-project setup.
33
+ if (!selectedProject) {
34
+ await renderProjectPicker();
35
+ return;
36
+ }
37
+
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
+
44
+ // Pre-resolve project initials + name for every distinct project_id in
45
+ // the fetched stories so `renderStoryCard` is synchronous and the header
46
+ // can show the human-friendly project name (e.g. "Linux Assistant
47
+ // Orchestrator" instead of the raw slug). Cached globally → at most one
48
+ // API round-trip per project over the session.
49
+ const uniqueProjectIds = [...new Set(stories.map((s) => s.project_id))];
50
+ await Promise.all(uniqueProjectIds.map(resolveProjectMeta));
51
+ if (selectedProject && !projectNameCache[selectedProject]) {
52
+ await resolveProjectMeta(selectedProject);
53
+ }
54
+ const projectDisplayName = selectedProject
55
+ ? (projectNameCache[selectedProject] || humaniseProjectName(selectedProject))
56
+ : '';
57
+
58
+ // KJC-TSK-0403: status/result ortogonal. 3 columnas (Pending /
59
+ // Running / Done) en vez de 4. Las HUs que fallaron NO van a una
60
+ // columna "Failed" — vuelven a Pending con result=fail y badge ✗.
61
+ // status=failed legacy se trata como pending (migración lazy).
62
+ const columns = {
63
+ pending: stories.filter((s) =>
64
+ ['pending', 'certified', 'needs_context', 'blocked', 'failed'].includes(s.status)
65
+ ),
66
+ running: stories.filter((s) => ['coding', 'reviewing'].includes(s.status)),
67
+ done: stories.filter((s) => s.status === 'done'),
68
+ };
69
+
70
+ if (stories.length === 0) {
71
+ app.innerHTML = renderEmptyState();
72
+ return;
73
+ }
74
+
75
+ // "Run plan" bulk action: visible when a project is selected AND at
76
+ // least one HU is still awaiting execution AND nothing's currently
77
+ // running (to avoid accidentally launching a second pipeline over a
78
+ // live one). Replaces the old "Mark as certified" button — the
79
+ // intermediate "certified" state was noise from the user's POV.
80
+ const awaitingCount = columns.pending.length;
81
+ const runningCount = columns.running.length;
82
+ const canRun = Boolean(selectedProject) && awaitingCount > 0 && runningCount === 0;
83
+ const isRunning = runningCount > 0;
84
+
85
+ // KJC-TSK-0403: 3 canonical lanes. Failed eliminado — HUs con
86
+ // result=fail aparecen en Pending con badge ✗.
87
+ const visibleColumns = [
88
+ { title: 'Pending', cls: 'pending', rows: columns.pending },
89
+ { title: 'Running', cls: 'running', rows: columns.running },
90
+ { title: 'Done', cls: 'done', rows: columns.done },
91
+ ];
92
+
93
+ app.innerHTML = `
94
+ <div class="section-header">
95
+ <span class="section-header__title" title="${selectedProject ? esc(selectedProject) : ''}">Story Board${selectedProject ? ` - ${esc(projectDisplayName)}` : ''}</span>
96
+ ${selectedProject ? `
97
+ <button class="control-btn project-rename-btn"
98
+ style="background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:0.9rem;padding:2px 6px;"
99
+ title="Renombrar este proyecto"
100
+ onclick="event.stopPropagation(); window.renameProjectModal('${esc(selectedProject)}', '${esc(projectDisplayName.replace(/'/g, '&#39;'))}');">✎</button>
101
+ ` : ''}
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>` : ''}
104
+ ${isRunning ? `
105
+ <button id="running-badge-btn" class="section-header__badge"
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;"
107
+ title="Abrir el log de la HU en marcha">
108
+ ⚙ ${runningCount} running…
109
+ </button>
110
+ <button id="stop-run-btn" class="control-btn"
111
+ style="padding:4px 10px;font-size:0.8rem;background:var(--color-red,#ef4444);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;"
112
+ title="Abortar todos los kj run en marcha de este proyecto (SIGTERM con escalado a SIGKILL tras 5s)">
113
+ ⏹ Stop
114
+ </button>
115
+ ` : ''}
116
+ ${lastOpenedLog ? `
117
+ <button class="control-btn" id="view-log-btn"
118
+ style="${isRunning ? '' : 'margin-left:auto;'}padding:6px 12px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;"
119
+ title="Re-open the log of the most recent run/command">
120
+ 📜 View log
121
+ </button>
122
+ ` : ''}
123
+ ${canRun ? `
124
+ <button class="control-btn" id="run-plan-btn"
125
+ style="margin-left:auto;padding:6px 14px;font-size:0.9rem;background:var(--color-green);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;"
126
+ title="Launch kj run --plan over every plan in this project">
127
+ ▶ Run plan (${awaitingCount} HU${awaitingCount === 1 ? '' : 's'})
128
+ </button>
129
+ ` : ''}
130
+ </div>
131
+ <div id="preflight-panel" class="preflight-panel" style="margin:8px 0;">
132
+ <div class="preflight-panel__loading" style="font-size:0.8rem;color:var(--text-muted);padding:6px 10px;">
133
+ Comprobando estado del proyecto…
134
+ </div>
135
+ </div>
136
+ <div id="rag-panel" class="rag-panel" style="margin:8px 0;display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
137
+ <input id="rag-query" type="text" placeholder="🔍 RAG search: ask anything about this project's plans / onboarding / code…"
138
+ style="flex:1;min-width:260px;padding:6px 10px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);" />
139
+ <select id="rag-scope" style="padding:6px 10px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">
140
+ <option value="all">All</option><option value="plans">Plans</option><option value="onboarding">Onboarding</option><option value="code">Code</option>
141
+ </select>
142
+ <button id="rag-search-btn" class="control-btn"
143
+ style="padding:6px 12px;font-size:0.85rem;background:var(--color-blue,#3b82f6);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">
144
+ Search
145
+ </button>
146
+ <div id="rag-results" style="flex-basis:100%;font-size:0.8rem;color:var(--text-muted);"></div>
147
+ </div>
148
+ <div id="plan-rollup-banner"></div>
149
+ <div class="kanban">
150
+ ${visibleColumns.map((c) => renderKanbanColumn(c.title, c.cls, c.rows)).join('')}
151
+ </div>
152
+ `;
153
+
154
+ if (canRun) {
155
+ document.getElementById('run-plan-btn').addEventListener('click', async () => {
156
+ // Pre-run safety: ask preflight if anything risky is missing
157
+ // (no remote, dirty tree, missing agents, etc.). The modal
158
+ // shows blockers + warnings in plain Spanish and the user
159
+ // can cancel or proceed anyway. If everything is green we
160
+ // skip the modal and run straight away.
161
+ const ok = await confirmRunWithPreflight(selectedProject);
162
+ if (!ok) return;
163
+ await runProject(selectedProject);
164
+ });
165
+ }
166
+ // Preflight panel is rendered AFTER the kanban so we can fetch
167
+ // it without blocking the initial paint. Replaces a placeholder
168
+ // div in-place (no full re-render).
169
+ renderPreflightPanel(selectedProject).catch(() => {});
170
+ // PR3: render the plan-level rollup banner if any plan in this
171
+ // project has a stamped outcome. Non-blocking — if it fails the
172
+ // user just doesn't see the banner.
173
+ renderPlanRollup(selectedProject).catch(() => {});
174
+ const viewLogBtn = document.getElementById('view-log-btn');
175
+ if (viewLogBtn && lastOpenedLog) {
176
+ viewLogBtn.addEventListener('click', () => openGenericLogPanel({
177
+ id: lastOpenedLog.id,
178
+ label: lastOpenedLog.label,
179
+ tailUrl: lastOpenedLog.tailUrl,
180
+ }));
181
+ }
182
+ // Wire the yellow "⚙ N running…" badge so it opens the live log of the
183
+ // first running HU. Without this the badge was decorative — and if the
184
+ // run was started before the current page load (refresh / new browser
185
+ // session) `lastOpenedLog` is null, so the "📜 View log" button isn't
186
+ // even rendered. Then the user has no way to see the live output.
187
+ //
188
+ // For a single-HU run the log lives at:
189
+ // ~/.karajan/hu-board-runs/<planId>--hu-<localHuId>.log
190
+ // which the /api/runs/:commandId/log endpoint serves when commandId
191
+ // matches the basename. The composite id stored in SQLite is
192
+ // "<projectId>::<localHuId>", so we strip the prefix to derive
193
+ // localHuId and concatenate with plan_id (already on the story row).
194
+ const runningBadgeBtn = document.getElementById('running-badge-btn');
195
+ if (runningBadgeBtn) {
196
+ runningBadgeBtn.addEventListener('click', async () => {
197
+ const running = stories.find((s) => s.status === 'coding' || s.status === 'reviewing');
198
+ if (!running) return;
199
+ const planId = running.plan_id;
200
+ if (!planId) {
201
+ await showError('Esta HU no tiene plan_id asociado, no puedo localizar su log.', { title: 'Sin log' });
202
+ return;
203
+ }
204
+ const localHuId = running.id.includes('::') ? running.id.split('::').pop() : running.id;
205
+ const logBasename = `${planId}--hu-${localHuId}`;
206
+ const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
207
+ lastOpenedLog = { id: logBasename, label: `HU ${localHuId}`, tailUrl };
208
+ openGenericLogPanel({ id: logBasename, label: `HU ${localHuId}`, tailUrl });
209
+ });
210
+ }
211
+
212
+ // KJC-TSK-0396: botón ⏹ Stop. Abortar todos los kj run vivos del
213
+ // proyecto. Si hay múltiples planes corriendo (caso raro pero
214
+ // posible), itera sobre cada planId único de las HUs running.
215
+ const stopRunBtn = document.getElementById('stop-run-btn');
216
+ if (stopRunBtn) {
217
+ stopRunBtn.addEventListener('click', async () => {
218
+ const runningHus = stories.filter((s) => s.status === 'coding' || s.status === 'reviewing');
219
+ const planIds = [...new Set(runningHus.map((s) => s.plan_id).filter(Boolean))];
220
+ if (planIds.length === 0) {
221
+ await showError('No hay plan_id asociado a las HUs en marcha.', { title: 'Sin runs' });
222
+ return;
223
+ }
224
+ const ok = await showConfirm(
225
+ `Esto matará ${planIds.length === 1 ? 'el proceso' : `los ${planIds.length} procesos`} del orquestador en marcha y dejará las HUs en curso (coding/reviewing) en pending para que puedas relanzarlas.\n\n¿Seguro?`,
226
+ { title: 'Abortar kj run', okLabel: 'Stop', cancelLabel: 'Cancelar', destructive: true }
227
+ );
228
+ if (!ok) return;
229
+ const summaries = [];
230
+ for (const planId of planIds) {
231
+ try {
232
+ const res = await fetch(`/api/runs/${encodeURIComponent(planId)}/stop`, {
233
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
234
+ });
235
+ const body = await res.json();
236
+ if (!res.ok) summaries.push(`${planId}: error ${body.error || res.status}`);
237
+ else summaries.push(`${planId}: ${body.stopped} SIGTERM, ${body.killed} SIGKILL, ${body.hu_reset_count || 0} HUs → pending`);
238
+ } catch (err) {
239
+ summaries.push(`${planId}: ${err.message}`);
240
+ }
241
+ }
242
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
243
+ await renderBoard();
244
+ if (summaries.some((s) => s.includes('SIGKILL') && !s.startsWith('0 ')) && summaries.some((s) => /\b[1-9]\d* SIGKILL/.test(s))) {
245
+ await showError(
246
+ `Algún proceso no respondió a SIGTERM en 5s y fue forzado con SIGKILL:\n\n${summaries.join('\n')}`,
247
+ { title: 'Forzado con SIGKILL' }
248
+ );
249
+ }
250
+ });
251
+ }
252
+
253
+ // RAG search panel (KJC-PCS-0049 Step 8) — input + scope + render hits.
254
+ const ragInput = document.getElementById('rag-query');
255
+ const ragScope = document.getElementById('rag-scope');
256
+ const ragBtn = document.getElementById('rag-search-btn');
257
+ const ragResults = document.getElementById('rag-results');
258
+ async function runRagSearch() {
259
+ const text = ragInput?.value?.trim();
260
+ if (!text) return;
261
+ ragResults.innerHTML = '<em>Searching…</em>';
262
+ try {
263
+ const res = await fetch('/api/rag/query', {
264
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({ text, topK: 5, scope: ragScope?.value || 'all' }),
266
+ });
267
+ const body = await res.json();
268
+ if (!res.ok) { ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(body.error || res.status)}</span>`; return; }
269
+ if (body.empty) { ragResults.innerHTML = '<em>No chunks indexed yet. Run <code>kj rag index</code> in this project\'s terminal first.</em>'; return; }
270
+ if (body.hits.length === 0) { ragResults.innerHTML = '<em>No hits.</em>'; return; }
271
+ ragResults.innerHTML = body.hits.map((h) => {
272
+ const label = h.metadata?.hu_id || h.metadata?.symbol || h.metadata?.headingPath?.join(' > ') || 'block';
273
+ const snippet = h.text.length > 240 ? `${esc(h.text.slice(0, 240))}…` : esc(h.text);
274
+ return `<div style="border-left:3px solid var(--color-blue,#3b82f6);padding:6px 10px;margin:6px 0;background:var(--bg-primary);">
275
+ <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px;">[${esc(h.kind)} · ${esc(label)} · score=${h.score.toFixed(4)}] ${esc(h.source)}</div>
276
+ <div style="font-size:0.85rem;white-space:pre-wrap;">${snippet}</div>
277
+ </div>`;
278
+ }).join('');
279
+ } catch (err) {
280
+ ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(err.message)}</span>`;
281
+ }
282
+ }
283
+ ragBtn?.addEventListener('click', runRagSearch);
284
+ ragInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') runRagSearch(); });
285
+ } catch (err) {
286
+ app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading board</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Renders a single kanban column.
292
+ * @param {string} title
293
+ * @param {string} cssClass
294
+ * @param {Array<object>} stories
295
+ * @returns {string}
296
+ */
297
+ function renderKanbanColumn(title, cssClass, stories) {
298
+ // Empty lanes render the header (so the user keeps the 4-column
299
+ // mental map) but no body placeholder — "No stories" text on four
300
+ // empty columns was noise on fresh plans.
301
+ return `
302
+ <div class="kanban__column kanban__column--${cssClass}" data-column="${cssClass}"${stories.length === 0 ? ' style="opacity:0.55"' : ''}>
303
+ <div class="kanban__column-header">
304
+ <span class="kanban__column-title">${title}</span>
305
+ <span class="kanban__column-count" data-column-count>${stories.length}</span>
306
+ </div>
307
+ ${stories.map(renderStoryCard).join('')}
308
+ </div>
309
+ `;
310
+ }
311
+
312
+ /**
313
+ * Renders a story card for the kanban board.
314
+ * @param {object} story
315
+ * @returns {string}
316
+ */
317
+ function renderStoryCard(story) {
318
+ const title = story.title || story.original_text || story.id;
319
+ const antipatterns = story.antipatterns ? JSON.parse(story.antipatterns) : [];
320
+ // Prefer denormalised counters stamped at sync time; fall back to
321
+ // parsing the JSON blob for pre-migration rows so the card still shows
322
+ // the AC count without a board DB nuke.
323
+ const acCount = typeof story.ac_count === 'number'
324
+ ? story.ac_count
325
+ : (story.acceptance_criteria ? JSON.parse(story.acceptance_criteria).length : 0);
326
+ const testCount = typeof story.test_count === 'number' ? story.test_count : 0;
327
+ const blockedBy = story.blocked_by ? JSON.parse(story.blocked_by) : [];
328
+ const initials = projectInitialsCache[story.project_id] || 'kj';
329
+ const shortId = shortStoryId(story, initials);
330
+
331
+ // Human-readable dep list: `lao-001, lao-003` instead of the raw HU ids.
332
+ const shortDep = (depId) => {
333
+ const m = /_(\d+)(?!.*\d)/.exec(depId);
334
+ return `${initials}-${m ? m[1] : '?'}`;
335
+ };
336
+
337
+ // Tests-first flag: a HU with zero acceptance_tests has no contract
338
+ // for the coder, so `Run plan` will refuse to execute it. Surface
339
+ // this on the card so the user edits the HU before trying to run.
340
+ const missingTestContract = testCount === 0 && ['pending', 'certified'].includes(story.status);
341
+
342
+ // PR4: per-HU ▶ Run button. Visible when:
343
+ // - the HU is in a re-runnable lifecycle state (certified / failed /
344
+ // pending / done — KJC-TSK-0394 step 2 adds `done` so the user can
345
+ // replay a HU whose `result` was fail or partial without having to
346
+ // "reset to pending" first) AND
347
+ // - it has at least one acceptance test (otherwise the run would
348
+ // refuse) AND
349
+ // - it isn't currently coding/reviewing AND
350
+ // - KJC-BUG-0048: it has no unresolved blocked_by deps. The card
351
+ // already shows "⏳ waits for: ..." below the title; the ▶ button
352
+ // must match that gating or the user can launch a HU whose deps
353
+ // don't exist yet, hitting the missing-prereq path at runtime.
354
+ const canRunHu = ['certified', 'failed', 'pending', 'done'].includes(story.status)
355
+ && testCount > 0
356
+ && !['coding', 'reviewing'].includes(story.status)
357
+ && blockedBy.length === 0;
358
+
359
+ return `
360
+ <div class="story-card" data-story-id="${esc(story.id)}" data-status="${esc(story.status || 'pending')}" onclick="showStoryDetail('${esc(story.id)}')">
361
+ <div class="story-card__id" title="${esc(story.id)}">
362
+ ${esc(shortId)}
363
+ ${canRunHu ? `
364
+ <button class="story-card__run-btn"
365
+ style="float:right;padding:1px 6px;font-size:0.7rem;background:var(--color-green);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;"
366
+ title="Lanzar solo esta HU"
367
+ onclick="event.stopPropagation(); window.runSingleHuFromCard('${esc(story.id)}', '${esc(title.replace(/'/g, '&#39;'))}');">▶</button>
368
+ ` : ''}
369
+ </div>
370
+ <div class="story-card__title">${esc(truncate(title, 100))}</div>
371
+ <div class="story-card__meta" style="gap:10px">
372
+ ${acCount > 0 ? `<span title="${acCount} acceptance criteria">📋 ${acCount} AC${acCount === 1 ? '' : 's'}</span>` : ''}
373
+ ${testCount > 0 ? `<span title="${testCount} acceptance tests declared">✅ ${testCount} test${testCount === 1 ? '' : 's'}</span>` : ''}
374
+ ${story.spec_section ? `<span title="Implements SPEC ${esc(story.spec_section)}" style="font-family:var(--font-mono, monospace)">📖 §${esc(story.spec_section)}</span>` : ''}
375
+ ${story.quality_total !== null ? `
376
+ <span class="story-card__score ${scoreClass(story.quality_total)}" title="INVEST score">
377
+ ${story.quality_total}/60 ${qualityBar(story.quality_total)}
378
+ </span>
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
+ })()}
384
+ </div>
385
+ ${missingTestContract ? `
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.">
387
+ ⚠ missing test contract
388
+ </div>
389
+ ` : ''}
390
+ ${blockedBy.length > 0 ? `
391
+ <div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--text-muted)" title="This HU waits on: ${esc(blockedBy.join(', '))}">
392
+ ⏳ waits for: ${blockedBy.map((d) => esc(shortDep(d))).join(', ')}
393
+ </div>
394
+ ` : (story.status === 'pending' || story.status === 'certified') && !missingTestContract ? `
395
+ <div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--color-green)" title="No dependencies — runs first on the next 'Run plan'">
396
+ 🟢 ready to run
397
+ </div>
398
+ ` : ''}
399
+ ${antipatterns.length > 0 ? `<div class="story-card__antipattern">${antipatterns.map((a) => esc(a)).join(', ')}</div>` : ''}
400
+ <div class="story-card__meta" style="margin-top:6px">
401
+ <span class="story-card__status status--${story.status}">${esc(story.status)}</span>
402
+ ${renderResultBadge(computeEffectiveResult(story))}
403
+ <span class="story-card__time">${timeAgo(story.updated_at)}</span>
404
+ ${renderOutcomeChip(story.outcome)}
405
+ </div>
406
+ </div>
407
+ `;
408
+ }
409
+
410
+ /**
411
+ * KJC-TSK-0394 step 5: result "efectivo" para la UI. Sobre el campo
412
+ * persistido `story.result` (si existe), o inferido del status legacy
413
+ * (done→pass, failed→fail).
414
+ */
415
+ function computeEffectiveResult(story) {
416
+ if (!story) return null;
417
+ if (story.result !== undefined && story.result !== null) return story.result;
418
+ if (story.status === 'done') return 'pass';
419
+ if (story.status === 'failed') return 'fail';
420
+ return null;
421
+ }
422
+
423
+ /**
424
+ * Render the per-HU `result` badge. Ortogonal al status.
425
+ * @param {string|null} result
426
+ * @returns {string} HTML
427
+ */
428
+ function renderResultBadge(result) {
429
+ if (!result) return '';
430
+ const icons = { pass: '✓', fail: '✗', partial: '~' };
431
+ const titles = {
432
+ pass: 'Última ejecución: todos los tests pasaron',
433
+ fail: 'Última ejecución: falló',
434
+ partial: 'Última ejecución: pasó parcialmente',
435
+ };
436
+ const icon = icons[result];
437
+ if (!icon) return '';
438
+ return `<span class="story-card__result result--${esc(result)}" title="${esc(titles[result])}">${icon}</span>`;
439
+ }
440
+
441
+ /**
442
+ * Render the per-HU outcome chip (📄 resumen). Tolerates both string and
443
+ * object shapes.
444
+ * @param {string|object|null} outcome
445
+ * @returns {string} HTML
446
+ */
447
+ function renderOutcomeChip(outcome) {
448
+ if (!outcome) return '';
449
+ let parsed;
450
+ try { parsed = typeof outcome === 'string' ? JSON.parse(outcome) : outcome; }
451
+ catch { return ''; }
452
+ if (!parsed || typeof parsed !== 'object') return '';
453
+ const summary = parsed.summary || `Resultado: ${parsed.status || 'desconocido'}`;
454
+ return `
455
+ <span class="story-card__outcome-chip"
456
+ style="margin-left:auto;padding:2px 8px;font-size:0.7rem;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;color:var(--text-muted);"
457
+ title="${esc(summary)}"
458
+ onclick="event.stopPropagation(); showOutcomeModal('${esc(JSON.stringify(parsed).replace(/'/g, '&#39;'))}');">
459
+ 📄 resumen
460
+ </span>
461
+ `;
462
+ }