karajan-code 3.0.0 → 3.1.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 (54) hide show
  1. package/README.md +3 -3
  2. package/docs/README.es.md +1 -1
  3. package/package.json +3 -2
  4. package/packages/hu-board/public/app.js +81 -4025
  5. package/packages/hu-board/public/index.html +20 -0
  6. package/packages/hu-board/public/utils/api.js +139 -0
  7. package/packages/hu-board/public/utils/board-view.js +453 -0
  8. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  9. package/packages/hu-board/public/utils/config-editor.js +181 -0
  10. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  11. package/packages/hu-board/public/utils/formatters.js +232 -0
  12. package/packages/hu-board/public/utils/graph-view.js +216 -0
  13. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  14. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  15. package/packages/hu-board/public/utils/log-panel.js +222 -0
  16. package/packages/hu-board/public/utils/modals.js +290 -0
  17. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  18. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  19. package/packages/hu-board/public/utils/project-actions.js +143 -0
  20. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  21. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  22. package/packages/hu-board/public/utils/server-push.js +252 -0
  23. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  24. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  25. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  26. package/packages/hu-board/src/config-yaml.js +14 -0
  27. package/packages/hu-board/src/db.js +15 -2
  28. package/packages/hu-board/src/routes/api.js +27 -9
  29. package/scripts/audit-test-diet.mjs +151 -0
  30. package/src/audit/harness-scorecard.js +52 -3
  31. package/src/audit/harness-section.js +6 -2
  32. package/src/checks/runner.js +10 -0
  33. package/src/cli/register-meta.js +15 -2
  34. package/src/cli/register-plan.js +10 -1
  35. package/src/commands/clean.js +62 -0
  36. package/src/commands/plan/generate.js +9 -3
  37. package/src/commands/sync.js +30 -2
  38. package/src/config/defaults.js +5 -0
  39. package/src/config/schema.js +2 -0
  40. package/src/orchestrator/config-init.js +1 -0
  41. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  42. package/src/orchestrator/stages/coder-stage.js +8 -2
  43. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  44. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  45. package/src/prompts/tool-correctness-judge.js +31 -0
  46. package/src/review/git-stash-helper.js +104 -0
  47. package/src/review/tdd-discipline.js +90 -0
  48. package/src/roles/tool-correctness-judge-role.js +78 -0
  49. package/src/session/mutators.js +4 -0
  50. package/src/sync/apply-canvas-patch.js +55 -0
  51. package/src/utils/paths.js +12 -0
  52. package/src/utils/repo-cleaner.js +79 -0
  53. package/src/utils/tool-call-extractor.js +56 -0
  54. package/src/utils/vector-store-orphans.js +89 -0
@@ -51,6 +51,26 @@
51
51
  <div class="modal" id="modal-content"></div>
52
52
  </div>
53
53
 
54
+ <script src="/utils/formatters.js"></script>
55
+ <script src="/utils/modals.js"></script>
56
+ <script src="/utils/api.js"></script>
57
+ <script src="/utils/sessions-view.js"></script>
58
+ <script src="/utils/board-view.js"></script>
59
+ <script src="/utils/dashboard-view.js"></script>
60
+ <script src="/utils/graph-view.js"></script>
61
+ <script src="/utils/project-picker-view.js"></script>
62
+ <script src="/utils/story-detail-view.js"></script>
63
+ <script src="/utils/preflight-view.js"></script>
64
+ <script src="/utils/plan-rollup-view.js"></script>
65
+ <script src="/utils/log-panel.js"></script>
66
+ <script src="/utils/hu-actions.js"></script>
67
+ <script src="/utils/project-actions.js"></script>
68
+ <script src="/utils/story-edit-form.js"></script>
69
+ <script src="/utils/config-editor.js"></script>
70
+ <script src="/utils/command-launcher.js"></script>
71
+ <script src="/utils/run-launcher.js"></script>
72
+ <script src="/utils/server-push.js"></script>
54
73
  <script src="/app.js"></script>
74
+ <script src="/utils/init-listeners.js"></script>
55
75
  </body>
56
76
  </html>
@@ -0,0 +1,139 @@
1
+ // KJC-TSK-0501 — API + polling helpers extracted from app.js (step 3/8).
2
+ // Loaded as a classic script before app.js (after modals.js) — all
3
+ // top-level declarations hoist to global scope so app.js consumes them
4
+ // transparently.
5
+ //
6
+ // Contents:
7
+ // - api(): thin JSON fetch wrapper used everywhere in the UI.
8
+ // - triggerSync(): POST /api/sync — fired on page load and the 🔄 button.
9
+ // - Standby banner: _standbyData state + refreshStandby() +
10
+ // _renderStandbyBanner() + _tickStandbyCountdowns() +
11
+ // startStandbyPolling() — KJC-TSK-0414 PR4.
12
+ // - Server-restart detector: pollServerVersion() + __versionBaseline +
13
+ // window.forceRefresh — KJC-TSK-0379.
14
+ //
15
+ // Dependencies: `esc()` from formatters.js (already global).
16
+ // `setInterval(pollServerVersion, 30_000)` + initial call fire at the
17
+ // bottom of the file so the detector starts as soon as the script loads.
18
+
19
+ async function api(path) {
20
+ const res = await fetch(path);
21
+ if (!res.ok) throw new Error(`API error: ${res.status}`);
22
+ return res.json();
23
+ }
24
+
25
+ // KJC-TSK-0414 PR4: standby banner — sesiones hibernadas pendientes de resume.
26
+ // Polleamos /api/standby cada 30s + tick countdown cada 1s.
27
+ let _standbyData = { sessions: [] };
28
+ let _standbyPollTimer = null;
29
+ let _standbyTickTimer = null;
30
+
31
+ function _fmtCountdown(ms) {
32
+ if (ms <= 0) return '✓ resume inmediato';
33
+ const s = Math.round(ms / 1000);
34
+ const h = Math.floor(s / 3600);
35
+ const m = Math.floor((s % 3600) / 60);
36
+ const sec = s % 60;
37
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
38
+ }
39
+
40
+ async function refreshStandby() {
41
+ try {
42
+ const r = await api('/api/standby');
43
+ _standbyData = r || { sessions: [] };
44
+ _renderStandbyBanner();
45
+ } catch { /* silencioso — endpoint nuevo, podría no existir en versiones viejas */ }
46
+ }
47
+
48
+ function _renderStandbyBanner() {
49
+ let banner = document.getElementById('standby-banner');
50
+ const sessions = _standbyData.sessions || [];
51
+ if (sessions.length === 0) {
52
+ if (banner) banner.remove();
53
+ return;
54
+ }
55
+ if (!banner) {
56
+ banner = document.createElement('div');
57
+ banner.id = 'standby-banner';
58
+ banner.style.cssText = 'background:rgba(251,191,36,0.12);border-left:4px solid #fbbf24;padding:10px 16px;margin:0 0 12px;font-size:0.85rem';
59
+ const app = document.getElementById('app');
60
+ if (app?.parentNode) app.parentNode.insertBefore(banner, app);
61
+ }
62
+ const now = Date.now();
63
+ banner.innerHTML = `
64
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
65
+ <strong>💤 ${sessions.length} sesión(es) hibernadas:</strong>
66
+ ${sessions.map((s) => {
67
+ const ms = new Date(s.cooldownUntil).getTime() - now;
68
+ const label = _fmtCountdown(ms);
69
+ const reason = s.reason || '?';
70
+ const planInfo = s.planId ? ` · ${s.planId}${s.huId ? '/' + s.huId : ''}` : '';
71
+ return `<span data-session="${esc(s.sessionId)}" data-cooldown="${esc(s.cooldownUntil)}" style="background:rgba(0,0,0,0.15);padding:4px 8px;border-radius:4px;font-family:monospace" title="${esc(reason)}${esc(planInfo)}">${esc(s.sessionId)} · <span class="standby-countdown">${label}</span></span>`;
72
+ }).join('')}
73
+ <span style="margin-left:auto;color:var(--text-muted);font-size:0.75rem">Auto-resume al llegar el cooldown (board) o <code>kj resume &lt;id&gt;</code></span>
74
+ </div>
75
+ `;
76
+ }
77
+
78
+ function _tickStandbyCountdowns() {
79
+ const banner = document.getElementById('standby-banner');
80
+ if (!banner) return;
81
+ const now = Date.now();
82
+ for (const chip of banner.querySelectorAll('[data-cooldown]')) {
83
+ const target = new Date(chip.getAttribute('data-cooldown')).getTime();
84
+ const span = chip.querySelector('.standby-countdown');
85
+ if (span) span.textContent = _fmtCountdown(target - now);
86
+ }
87
+ }
88
+
89
+ function startStandbyPolling() {
90
+ if (_standbyPollTimer || _standbyTickTimer) return;
91
+ refreshStandby();
92
+ _standbyPollTimer = setInterval(refreshStandby, 30_000);
93
+ _standbyTickTimer = setInterval(_tickStandbyCountdowns, 1000);
94
+ }
95
+
96
+ /**
97
+ * Trigger a full re-scan of disk data (hu-stories + sessions).
98
+ * Called on page load and via the sync button.
99
+ */
100
+ async function triggerSync() {
101
+ try {
102
+ await fetch('/api/sync', { method: 'POST' });
103
+ } catch { /* ignore — board may not support sync yet */ }
104
+ }
105
+
106
+ // --- Server-restart detector (KJC-TSK-0379) ---------------------------
107
+ // Poll /api/version every 30s. First response anchors the baseline; if
108
+ // `boot_time` later differs, the server restarted (likely after a `kj
109
+ // board start`) and the client reloads to pick up any new HTML/JS
110
+ // served with `Cache-Control: no-store`. Avoids the "I killed the
111
+ // server but the browser keeps showing stale UI" pain.
112
+ let __versionBaseline = null;
113
+ async function pollServerVersion() {
114
+ try {
115
+ const res = await api('/version');
116
+ if (!__versionBaseline) { __versionBaseline = res; return; }
117
+ if (res.boot_time !== __versionBaseline.boot_time) {
118
+ console.info('[hu-board] server restart detected, reloading...');
119
+ window.forceRefresh();
120
+ }
121
+ } catch (_) { /* network blip; try next tick */ }
122
+ }
123
+ setInterval(pollServerVersion, 30_000);
124
+ pollServerVersion();
125
+
126
+ // Manual escape hatch: bound to the 🔄 button in the header. Wipes all
127
+ // caches the browser may be holding (Cache API + sessionStorage) and
128
+ // hard-reloads. Useful when the user thinks something is wrong even
129
+ // though the version-poll hasn't fired.
130
+ window.forceRefresh = async function forceRefresh() {
131
+ try {
132
+ if ('caches' in window) {
133
+ const keys = await caches.keys();
134
+ await Promise.all(keys.map((k) => caches.delete(k)));
135
+ }
136
+ sessionStorage.clear();
137
+ } catch (_) { /* best effort */ }
138
+ window.location.reload();
139
+ };
@@ -0,0 +1,453 @@
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 = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
39
+
40
+ // Pre-resolve project initials + name for every distinct project_id in
41
+ // the fetched stories so `renderStoryCard` is synchronous and the header
42
+ // can show the human-friendly project name (e.g. "Linux Assistant
43
+ // Orchestrator" instead of the raw slug). Cached globally → at most one
44
+ // API round-trip per project over the session.
45
+ const uniqueProjectIds = [...new Set(stories.map((s) => s.project_id))];
46
+ await Promise.all(uniqueProjectIds.map(resolveProjectMeta));
47
+ if (selectedProject && !projectNameCache[selectedProject]) {
48
+ await resolveProjectMeta(selectedProject);
49
+ }
50
+ const projectDisplayName = selectedProject
51
+ ? (projectNameCache[selectedProject] || humaniseProjectName(selectedProject))
52
+ : '';
53
+
54
+ // KJC-TSK-0403: status/result ortogonal. 3 columnas (Pending /
55
+ // Running / Done) en vez de 4. Las HUs que fallaron NO van a una
56
+ // columna "Failed" — vuelven a Pending con result=fail y badge ✗.
57
+ // status=failed legacy se trata como pending (migración lazy).
58
+ const columns = {
59
+ pending: stories.filter((s) =>
60
+ ['pending', 'certified', 'needs_context', 'blocked', 'failed'].includes(s.status)
61
+ ),
62
+ running: stories.filter((s) => ['coding', 'reviewing'].includes(s.status)),
63
+ done: stories.filter((s) => s.status === 'done'),
64
+ };
65
+
66
+ if (stories.length === 0) {
67
+ app.innerHTML = renderEmptyState();
68
+ return;
69
+ }
70
+
71
+ // "Run plan" bulk action: visible when a project is selected AND at
72
+ // least one HU is still awaiting execution AND nothing's currently
73
+ // running (to avoid accidentally launching a second pipeline over a
74
+ // live one). Replaces the old "Mark as certified" button — the
75
+ // intermediate "certified" state was noise from the user's POV.
76
+ const awaitingCount = columns.pending.length;
77
+ const runningCount = columns.running.length;
78
+ const canRun = Boolean(selectedProject) && awaitingCount > 0 && runningCount === 0;
79
+ const isRunning = runningCount > 0;
80
+
81
+ // KJC-TSK-0403: 3 canonical lanes. Failed eliminado — HUs con
82
+ // result=fail aparecen en Pending con badge ✗.
83
+ const visibleColumns = [
84
+ { title: 'Pending', cls: 'pending', rows: columns.pending },
85
+ { title: 'Running', cls: 'running', rows: columns.running },
86
+ { title: 'Done', cls: 'done', rows: columns.done },
87
+ ];
88
+
89
+ app.innerHTML = `
90
+ <div class="section-header">
91
+ <span class="section-header__title" title="${selectedProject ? esc(selectedProject) : ''}">Story Board${selectedProject ? ` - ${esc(projectDisplayName)}` : ''}</span>
92
+ ${selectedProject ? `
93
+ <button class="control-btn project-rename-btn"
94
+ style="background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:0.9rem;padding:2px 6px;"
95
+ title="Renombrar este proyecto"
96
+ onclick="event.stopPropagation(); window.renameProjectModal('${esc(selectedProject)}', '${esc(projectDisplayName.replace(/'/g, '&#39;'))}');">✎</button>
97
+ ` : ''}
98
+ <span class="section-header__count">${stories.length} stories</span>
99
+ ${isRunning ? `
100
+ <button id="running-badge-btn" class="section-header__badge"
101
+ 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;"
102
+ title="Abrir el log de la HU en marcha">
103
+ ⚙ ${runningCount} running…
104
+ </button>
105
+ <button id="stop-run-btn" class="control-btn"
106
+ 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;"
107
+ title="Abortar todos los kj run en marcha de este proyecto (SIGTERM con escalado a SIGKILL tras 5s)">
108
+ ⏹ Stop
109
+ </button>
110
+ ` : ''}
111
+ ${lastOpenedLog ? `
112
+ <button class="control-btn" id="view-log-btn"
113
+ 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;"
114
+ title="Re-open the log of the most recent run/command">
115
+ 📜 View log
116
+ </button>
117
+ ` : ''}
118
+ ${canRun ? `
119
+ <button class="control-btn" id="run-plan-btn"
120
+ 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;"
121
+ title="Launch kj run --plan over every plan in this project">
122
+ ▶ Run plan (${awaitingCount} HU${awaitingCount === 1 ? '' : 's'})
123
+ </button>
124
+ ` : ''}
125
+ </div>
126
+ <div id="preflight-panel" class="preflight-panel" style="margin:8px 0;">
127
+ <div class="preflight-panel__loading" style="font-size:0.8rem;color:var(--text-muted);padding:6px 10px;">
128
+ Comprobando estado del proyecto…
129
+ </div>
130
+ </div>
131
+ <div id="rag-panel" class="rag-panel" style="margin:8px 0;display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
132
+ <input id="rag-query" type="text" placeholder="🔍 RAG search: ask anything about this project's plans / onboarding / code…"
133
+ 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);" />
134
+ <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);">
135
+ <option value="all">All</option><option value="plans">Plans</option><option value="onboarding">Onboarding</option><option value="code">Code</option>
136
+ </select>
137
+ <button id="rag-search-btn" class="control-btn"
138
+ 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;">
139
+ Search
140
+ </button>
141
+ <div id="rag-results" style="flex-basis:100%;font-size:0.8rem;color:var(--text-muted);"></div>
142
+ </div>
143
+ <div id="plan-rollup-banner"></div>
144
+ <div class="kanban">
145
+ ${visibleColumns.map((c) => renderKanbanColumn(c.title, c.cls, c.rows)).join('')}
146
+ </div>
147
+ `;
148
+
149
+ if (canRun) {
150
+ document.getElementById('run-plan-btn').addEventListener('click', async () => {
151
+ // Pre-run safety: ask preflight if anything risky is missing
152
+ // (no remote, dirty tree, missing agents, etc.). The modal
153
+ // shows blockers + warnings in plain Spanish and the user
154
+ // can cancel or proceed anyway. If everything is green we
155
+ // skip the modal and run straight away.
156
+ const ok = await confirmRunWithPreflight(selectedProject);
157
+ if (!ok) return;
158
+ await runProject(selectedProject);
159
+ });
160
+ }
161
+ // Preflight panel is rendered AFTER the kanban so we can fetch
162
+ // it without blocking the initial paint. Replaces a placeholder
163
+ // div in-place (no full re-render).
164
+ renderPreflightPanel(selectedProject).catch(() => {});
165
+ // PR3: render the plan-level rollup banner if any plan in this
166
+ // project has a stamped outcome. Non-blocking — if it fails the
167
+ // user just doesn't see the banner.
168
+ renderPlanRollup(selectedProject).catch(() => {});
169
+ const viewLogBtn = document.getElementById('view-log-btn');
170
+ if (viewLogBtn && lastOpenedLog) {
171
+ viewLogBtn.addEventListener('click', () => openGenericLogPanel({
172
+ id: lastOpenedLog.id,
173
+ label: lastOpenedLog.label,
174
+ tailUrl: lastOpenedLog.tailUrl,
175
+ }));
176
+ }
177
+ // Wire the yellow "⚙ N running…" badge so it opens the live log of the
178
+ // first running HU. Without this the badge was decorative — and if the
179
+ // run was started before the current page load (refresh / new browser
180
+ // session) `lastOpenedLog` is null, so the "📜 View log" button isn't
181
+ // even rendered. Then the user has no way to see the live output.
182
+ //
183
+ // For a single-HU run the log lives at:
184
+ // ~/.karajan/hu-board-runs/<planId>--hu-<localHuId>.log
185
+ // which the /api/runs/:commandId/log endpoint serves when commandId
186
+ // matches the basename. The composite id stored in SQLite is
187
+ // "<projectId>::<localHuId>", so we strip the prefix to derive
188
+ // localHuId and concatenate with plan_id (already on the story row).
189
+ const runningBadgeBtn = document.getElementById('running-badge-btn');
190
+ if (runningBadgeBtn) {
191
+ runningBadgeBtn.addEventListener('click', async () => {
192
+ const running = stories.find((s) => s.status === 'coding' || s.status === 'reviewing');
193
+ if (!running) return;
194
+ const planId = running.plan_id;
195
+ if (!planId) {
196
+ await showError('Esta HU no tiene plan_id asociado, no puedo localizar su log.', { title: 'Sin log' });
197
+ return;
198
+ }
199
+ const localHuId = running.id.includes('::') ? running.id.split('::').pop() : running.id;
200
+ const logBasename = `${planId}--hu-${localHuId}`;
201
+ const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
202
+ lastOpenedLog = { id: logBasename, label: `HU ${localHuId}`, tailUrl };
203
+ openGenericLogPanel({ id: logBasename, label: `HU ${localHuId}`, tailUrl });
204
+ });
205
+ }
206
+
207
+ // KJC-TSK-0396: botón ⏹ Stop. Abortar todos los kj run vivos del
208
+ // proyecto. Si hay múltiples planes corriendo (caso raro pero
209
+ // posible), itera sobre cada planId único de las HUs running.
210
+ const stopRunBtn = document.getElementById('stop-run-btn');
211
+ if (stopRunBtn) {
212
+ stopRunBtn.addEventListener('click', async () => {
213
+ const runningHus = stories.filter((s) => s.status === 'coding' || s.status === 'reviewing');
214
+ const planIds = [...new Set(runningHus.map((s) => s.plan_id).filter(Boolean))];
215
+ if (planIds.length === 0) {
216
+ await showError('No hay plan_id asociado a las HUs en marcha.', { title: 'Sin runs' });
217
+ return;
218
+ }
219
+ const ok = await showConfirm(
220
+ `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?`,
221
+ { title: 'Abortar kj run', okLabel: 'Stop', cancelLabel: 'Cancelar', destructive: true }
222
+ );
223
+ if (!ok) return;
224
+ const summaries = [];
225
+ for (const planId of planIds) {
226
+ try {
227
+ const res = await fetch(`/api/runs/${encodeURIComponent(planId)}/stop`, {
228
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
229
+ });
230
+ const body = await res.json();
231
+ if (!res.ok) summaries.push(`${planId}: error ${body.error || res.status}`);
232
+ else summaries.push(`${planId}: ${body.stopped} SIGTERM, ${body.killed} SIGKILL, ${body.hu_reset_count || 0} HUs → pending`);
233
+ } catch (err) {
234
+ summaries.push(`${planId}: ${err.message}`);
235
+ }
236
+ }
237
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
238
+ await renderBoard();
239
+ if (summaries.some((s) => s.includes('SIGKILL') && !s.startsWith('0 ')) && summaries.some((s) => /\b[1-9]\d* SIGKILL/.test(s))) {
240
+ await showError(
241
+ `Algún proceso no respondió a SIGTERM en 5s y fue forzado con SIGKILL:\n\n${summaries.join('\n')}`,
242
+ { title: 'Forzado con SIGKILL' }
243
+ );
244
+ }
245
+ });
246
+ }
247
+
248
+ // RAG search panel (KJC-PCS-0049 Step 8) — input + scope + render hits.
249
+ const ragInput = document.getElementById('rag-query');
250
+ const ragScope = document.getElementById('rag-scope');
251
+ const ragBtn = document.getElementById('rag-search-btn');
252
+ const ragResults = document.getElementById('rag-results');
253
+ async function runRagSearch() {
254
+ const text = ragInput?.value?.trim();
255
+ if (!text) return;
256
+ ragResults.innerHTML = '<em>Searching…</em>';
257
+ try {
258
+ const res = await fetch('/api/rag/query', {
259
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ text, topK: 5, scope: ragScope?.value || 'all' }),
261
+ });
262
+ const body = await res.json();
263
+ if (!res.ok) { ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(body.error || res.status)}</span>`; return; }
264
+ if (body.empty) { ragResults.innerHTML = '<em>No chunks indexed yet. Run <code>kj rag index</code> in this project\'s terminal first.</em>'; return; }
265
+ if (body.hits.length === 0) { ragResults.innerHTML = '<em>No hits.</em>'; return; }
266
+ ragResults.innerHTML = body.hits.map((h) => {
267
+ const label = h.metadata?.hu_id || h.metadata?.symbol || h.metadata?.headingPath?.join(' > ') || 'block';
268
+ const snippet = h.text.length > 240 ? `${esc(h.text.slice(0, 240))}…` : esc(h.text);
269
+ return `<div style="border-left:3px solid var(--color-blue,#3b82f6);padding:6px 10px;margin:6px 0;background:var(--bg-primary);">
270
+ <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>
271
+ <div style="font-size:0.85rem;white-space:pre-wrap;">${snippet}</div>
272
+ </div>`;
273
+ }).join('');
274
+ } catch (err) {
275
+ ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(err.message)}</span>`;
276
+ }
277
+ }
278
+ ragBtn?.addEventListener('click', runRagSearch);
279
+ ragInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') runRagSearch(); });
280
+ } catch (err) {
281
+ 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>`;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Renders a single kanban column.
287
+ * @param {string} title
288
+ * @param {string} cssClass
289
+ * @param {Array<object>} stories
290
+ * @returns {string}
291
+ */
292
+ function renderKanbanColumn(title, cssClass, stories) {
293
+ // Empty lanes render the header (so the user keeps the 4-column
294
+ // mental map) but no body placeholder — "No stories" text on four
295
+ // empty columns was noise on fresh plans.
296
+ return `
297
+ <div class="kanban__column kanban__column--${cssClass}" data-column="${cssClass}"${stories.length === 0 ? ' style="opacity:0.55"' : ''}>
298
+ <div class="kanban__column-header">
299
+ <span class="kanban__column-title">${title}</span>
300
+ <span class="kanban__column-count" data-column-count>${stories.length}</span>
301
+ </div>
302
+ ${stories.map(renderStoryCard).join('')}
303
+ </div>
304
+ `;
305
+ }
306
+
307
+ /**
308
+ * Renders a story card for the kanban board.
309
+ * @param {object} story
310
+ * @returns {string}
311
+ */
312
+ function renderStoryCard(story) {
313
+ const title = story.title || story.original_text || story.id;
314
+ const antipatterns = story.antipatterns ? JSON.parse(story.antipatterns) : [];
315
+ // Prefer denormalised counters stamped at sync time; fall back to
316
+ // parsing the JSON blob for pre-migration rows so the card still shows
317
+ // the AC count without a board DB nuke.
318
+ const acCount = typeof story.ac_count === 'number'
319
+ ? story.ac_count
320
+ : (story.acceptance_criteria ? JSON.parse(story.acceptance_criteria).length : 0);
321
+ const testCount = typeof story.test_count === 'number' ? story.test_count : 0;
322
+ const blockedBy = story.blocked_by ? JSON.parse(story.blocked_by) : [];
323
+ const initials = projectInitialsCache[story.project_id] || 'kj';
324
+ const shortId = shortStoryId(story, initials);
325
+
326
+ // Human-readable dep list: `lao-001, lao-003` instead of the raw HU ids.
327
+ const shortDep = (depId) => {
328
+ const m = /_(\d+)(?!.*\d)/.exec(depId);
329
+ return `${initials}-${m ? m[1] : '?'}`;
330
+ };
331
+
332
+ // Tests-first flag: a HU with zero acceptance_tests has no contract
333
+ // for the coder, so `Run plan` will refuse to execute it. Surface
334
+ // this on the card so the user edits the HU before trying to run.
335
+ const missingTestContract = testCount === 0 && ['pending', 'certified'].includes(story.status);
336
+
337
+ // PR4: per-HU ▶ Run button. Visible when:
338
+ // - the HU is in a re-runnable lifecycle state (certified / failed /
339
+ // pending / done — KJC-TSK-0394 step 2 adds `done` so the user can
340
+ // replay a HU whose `result` was fail or partial without having to
341
+ // "reset to pending" first) AND
342
+ // - it has at least one acceptance test (otherwise the run would
343
+ // refuse) AND
344
+ // - it isn't currently coding/reviewing AND
345
+ // - KJC-BUG-0048: it has no unresolved blocked_by deps. The card
346
+ // already shows "⏳ waits for: ..." below the title; the ▶ button
347
+ // must match that gating or the user can launch a HU whose deps
348
+ // don't exist yet, hitting the missing-prereq path at runtime.
349
+ const canRunHu = ['certified', 'failed', 'pending', 'done'].includes(story.status)
350
+ && testCount > 0
351
+ && !['coding', 'reviewing'].includes(story.status)
352
+ && blockedBy.length === 0;
353
+
354
+ return `
355
+ <div class="story-card" data-story-id="${esc(story.id)}" data-status="${esc(story.status || 'pending')}" onclick="showStoryDetail('${esc(story.id)}')">
356
+ <div class="story-card__id" title="${esc(story.id)}">
357
+ ${esc(shortId)}
358
+ ${canRunHu ? `
359
+ <button class="story-card__run-btn"
360
+ 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;"
361
+ title="Lanzar solo esta HU"
362
+ onclick="event.stopPropagation(); window.runSingleHuFromCard('${esc(story.id)}', '${esc(title.replace(/'/g, '&#39;'))}');">▶</button>
363
+ ` : ''}
364
+ </div>
365
+ <div class="story-card__title">${esc(truncate(title, 100))}</div>
366
+ <div class="story-card__meta" style="gap:10px">
367
+ ${acCount > 0 ? `<span title="${acCount} acceptance criteria">📋 ${acCount} AC${acCount === 1 ? '' : 's'}</span>` : ''}
368
+ ${testCount > 0 ? `<span title="${testCount} acceptance tests declared">✅ ${testCount} test${testCount === 1 ? '' : 's'}</span>` : ''}
369
+ ${story.spec_section ? `<span title="Implements SPEC ${esc(story.spec_section)}" style="font-family:var(--font-mono, monospace)">📖 §${esc(story.spec_section)}</span>` : ''}
370
+ ${story.quality_total !== null ? `
371
+ <span class="story-card__score ${scoreClass(story.quality_total)}" title="INVEST score">
372
+ ${story.quality_total}/60 ${qualityBar(story.quality_total)}
373
+ </span>
374
+ ` : ''}
375
+ </div>
376
+ ${missingTestContract ? `
377
+ <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.">
378
+ ⚠ missing test contract
379
+ </div>
380
+ ` : ''}
381
+ ${blockedBy.length > 0 ? `
382
+ <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(', '))}">
383
+ ⏳ waits for: ${blockedBy.map((d) => esc(shortDep(d))).join(', ')}
384
+ </div>
385
+ ` : (story.status === 'pending' || story.status === 'certified') && !missingTestContract ? `
386
+ <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'">
387
+ 🟢 ready to run
388
+ </div>
389
+ ` : ''}
390
+ ${antipatterns.length > 0 ? `<div class="story-card__antipattern">${antipatterns.map((a) => esc(a)).join(', ')}</div>` : ''}
391
+ <div class="story-card__meta" style="margin-top:6px">
392
+ <span class="story-card__status status--${story.status}">${esc(story.status)}</span>
393
+ ${renderResultBadge(computeEffectiveResult(story))}
394
+ <span class="story-card__time">${timeAgo(story.updated_at)}</span>
395
+ ${renderOutcomeChip(story.outcome)}
396
+ </div>
397
+ </div>
398
+ `;
399
+ }
400
+
401
+ /**
402
+ * KJC-TSK-0394 step 5: result "efectivo" para la UI. Sobre el campo
403
+ * persistido `story.result` (si existe), o inferido del status legacy
404
+ * (done→pass, failed→fail).
405
+ */
406
+ function computeEffectiveResult(story) {
407
+ if (!story) return null;
408
+ if (story.result !== undefined && story.result !== null) return story.result;
409
+ if (story.status === 'done') return 'pass';
410
+ if (story.status === 'failed') return 'fail';
411
+ return null;
412
+ }
413
+
414
+ /**
415
+ * Render the per-HU `result` badge. Ortogonal al status.
416
+ * @param {string|null} result
417
+ * @returns {string} HTML
418
+ */
419
+ function renderResultBadge(result) {
420
+ if (!result) return '';
421
+ const icons = { pass: '✓', fail: '✗', partial: '~' };
422
+ const titles = {
423
+ pass: 'Última ejecución: todos los tests pasaron',
424
+ fail: 'Última ejecución: falló',
425
+ partial: 'Última ejecución: pasó parcialmente',
426
+ };
427
+ const icon = icons[result];
428
+ if (!icon) return '';
429
+ return `<span class="story-card__result result--${esc(result)}" title="${esc(titles[result])}">${icon}</span>`;
430
+ }
431
+
432
+ /**
433
+ * Render the per-HU outcome chip (📄 resumen). Tolerates both string and
434
+ * object shapes.
435
+ * @param {string|object|null} outcome
436
+ * @returns {string} HTML
437
+ */
438
+ function renderOutcomeChip(outcome) {
439
+ if (!outcome) return '';
440
+ let parsed;
441
+ try { parsed = typeof outcome === 'string' ? JSON.parse(outcome) : outcome; }
442
+ catch { return ''; }
443
+ if (!parsed || typeof parsed !== 'object') return '';
444
+ const summary = parsed.summary || `Resultado: ${parsed.status || 'desconocido'}`;
445
+ return `
446
+ <span class="story-card__outcome-chip"
447
+ 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);"
448
+ title="${esc(summary)}"
449
+ onclick="event.stopPropagation(); showOutcomeModal('${esc(JSON.stringify(parsed).replace(/'/g, '&#39;'))}');">
450
+ 📄 resumen
451
+ </span>
452
+ `;
453
+ }