karajan-code 2.19.0 → 2.19.2

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 (53) hide show
  1. package/README.md +4 -0
  2. package/package.json +9 -1
  3. package/packages/hu-board/README.md +69 -0
  4. package/packages/hu-board/package.json +23 -0
  5. package/packages/hu-board/public/app.js +4038 -0
  6. package/packages/hu-board/public/index.html +55 -0
  7. package/packages/hu-board/public/pipeline.css +132 -0
  8. package/packages/hu-board/public/pipeline.html +48 -0
  9. package/packages/hu-board/public/pipeline.js +200 -0
  10. package/packages/hu-board/public/styles.css +918 -0
  11. package/packages/hu-board/src/auth.js +92 -0
  12. package/packages/hu-board/src/boot-info.js +16 -0
  13. package/packages/hu-board/src/cleanup-zombies.js +142 -0
  14. package/packages/hu-board/src/command-runner.js +134 -0
  15. package/packages/hu-board/src/config-yaml.js +285 -0
  16. package/packages/hu-board/src/db.js +739 -0
  17. package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
  18. package/packages/hu-board/src/event-bus.js +58 -0
  19. package/packages/hu-board/src/format.js +83 -0
  20. package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
  21. package/packages/hu-board/src/journal-parser.js +180 -0
  22. package/packages/hu-board/src/plan-mutations.js +422 -0
  23. package/packages/hu-board/src/preflight.js +285 -0
  24. package/packages/hu-board/src/routes/api.js +1271 -0
  25. package/packages/hu-board/src/routes/pipeline.js +45 -0
  26. package/packages/hu-board/src/run-tracker.js +121 -0
  27. package/packages/hu-board/src/server.js +435 -0
  28. package/packages/hu-board/src/sync.js +626 -0
  29. package/packages/hu-board/src/token-store.js +57 -0
  30. package/packages/hu-board/src/zombie-reaper.js +207 -0
  31. package/src/audit/webperf-input.js +4 -7
  32. package/src/checks/dir-setup.js +5 -3
  33. package/src/cli/register-pipeline.js +1 -0
  34. package/src/cli/register-plan.js +1 -0
  35. package/src/commands/plan/generate.js +15 -0
  36. package/src/commands/run.js +19 -1
  37. package/src/commands/webperf.js +4 -6
  38. package/src/config/role-resolver.js +3 -1
  39. package/src/mcp/handlers/direct-handlers.js +3 -0
  40. package/src/mcp/handlers/run-handler.js +5 -0
  41. package/src/mcp/tools.js +4 -2
  42. package/src/prompts/spec-reviewer.js +66 -0
  43. package/src/roles/perf-role.js +4 -4
  44. package/src/roles/spec-reviewer-role.js +106 -0
  45. package/src/sonar/api.js +17 -3
  46. package/src/sonar/token-recovery.js +67 -0
  47. package/src/spec-review/refine-loop.js +58 -0
  48. package/src/spec-review/run-spec-review.js +70 -0
  49. package/src/utils/board-prompt-bridge.js +4 -5
  50. package/src/utils/display/spec-findings.js +44 -0
  51. package/src/utils/paths.js +15 -0
  52. package/src/utils/run-registry.js +4 -4
  53. package/templates/roles/spec-reviewer.md +38 -0
@@ -0,0 +1,4038 @@
1
+ /**
2
+ * Karajan HU Board - Frontend Application
3
+ * Vanilla JS single-page app with hash-based routing.
4
+ */
5
+
6
+ /**
7
+ * Scoped-project mode: when the server serves `/p/<slug>`, the UI boots
8
+ * pre-filtered to that project. Multi-project affordances (project
9
+ * dropdown, Sessions tab, Pipeline link, Dashboard card grid) are hidden
10
+ * because they make no sense here. Driven by dogfood feedback: the user
11
+ * wanted one board per project, not a global view with 2 000 stories.
12
+ */
13
+ const SCOPED_PREFIX = '/p/';
14
+ const scopedProjectSlug = window.location.pathname.startsWith(SCOPED_PREFIX)
15
+ ? decodeURIComponent(window.location.pathname.slice(SCOPED_PREFIX.length)).replace(/\/+$/, '')
16
+ : null;
17
+
18
+ if (scopedProjectSlug) {
19
+ document.body.classList.add('scoped-mode');
20
+ }
21
+
22
+ /**
23
+ * Current view — Board is the default everywhere, not just in scoped mode.
24
+ * Dogfood quote: "Board vista por defecto. Dashboard es algo extra que a
25
+ * priori no me interesa, pq me interesan los proyectos." The Kanban is
26
+ * the thing the user wants to see first; Dashboard stays accessible via
27
+ * its nav button for the (rare) multi-project overview case but is never
28
+ * the landing view.
29
+ */
30
+ let currentView = 'board';
31
+
32
+ /** @type {string} Selected project ID (empty = all) */
33
+ let selectedProject = scopedProjectSlug || '';
34
+
35
+ /** @type {number | null} Auto-refresh interval ID */
36
+ let refreshInterval = null;
37
+
38
+ // ---- Format helpers (KEEP IN SYNC with packages/hu-board/src/format.js) ----
39
+ //
40
+ // Browsers don't share Node's module loader and `app.js` is a classic
41
+ // (non-module) <script>, so the helpers below are duplicated inline.
42
+ // The source of truth for tests lives at packages/hu-board/src/format.js;
43
+ // any change to one MUST be made to the other. Tests in
44
+ // packages/hu-board/tests/format.test.js pin the contract.
45
+
46
+ /** Format an ISO 8601 timestamp as HH:MM (24h, server-local). Falls back to "—". */
47
+ function formatHHMM(iso) {
48
+ if (!iso) return "—";
49
+ const d = new Date(iso);
50
+ if (Number.isNaN(d.getTime())) return "—";
51
+ const hh = String(d.getHours()).padStart(2, "0");
52
+ const mm = String(d.getMinutes()).padStart(2, "0");
53
+ return `${hh}:${mm}`;
54
+ }
55
+
56
+ /** Truncate a task description to <=max chars, ellipsis when truncated. */
57
+ function shortTask(text, max = 60) {
58
+ if (!text) return "";
59
+ const collapsed = String(text).replace(/\s+/g, " ").trim();
60
+ if (collapsed.length <= max) return collapsed;
61
+ return collapsed.slice(0, max - 1).trimEnd() + "…";
62
+ }
63
+
64
+ /**
65
+ * Build the human-readable label tuple used by the Sessions view.
66
+ * Replaces the cryptic `s_2026-05-07T08-35-58-010Z` rendered before
67
+ * KJC fix on 2026-05-07. The id is preserved as `idChip` so the user
68
+ * can still grab it for `kj resume <id>`.
69
+ *
70
+ * @returns {{ title: string, subtitle: string, idChip: string }}
71
+ */
72
+ function formatSessionLabel(session) {
73
+ if (!session || !session.id) {
74
+ return { title: "(no session)", subtitle: "", idChip: "" };
75
+ }
76
+ const projectName = (session.project_name || "").trim();
77
+ const time = formatHHMM(session.created_at);
78
+ const projectPart = projectName
79
+ ? projectName.length > 40
80
+ ? projectName.slice(0, 39) + "…"
81
+ : projectName
82
+ : session.id;
83
+ const title = time !== "—" ? `${projectPart} · ${time}` : projectPart;
84
+ return {
85
+ title,
86
+ subtitle: shortTask(session.task, 60),
87
+ idChip: session.id,
88
+ };
89
+ }
90
+
91
+ // ---- API Layer ----
92
+
93
+ /**
94
+ * Fetches JSON from the API.
95
+ * @param {string} path - API path (e.g., '/api/dashboard')
96
+ * @returns {Promise<any>}
97
+ */
98
+ async function api(path) {
99
+ const res = await fetch(path);
100
+ if (!res.ok) throw new Error(`API error: ${res.status}`);
101
+ return res.json();
102
+ }
103
+
104
+ // KJC-TSK-0414 PR4: standby banner — sesiones hibernadas pendientes de resume.
105
+ // Polleamos /api/standby cada 30s + tick countdown cada 1s.
106
+ let _standbyData = { sessions: [] };
107
+ let _standbyPollTimer = null;
108
+ let _standbyTickTimer = null;
109
+
110
+ function _fmtCountdown(ms) {
111
+ if (ms <= 0) return '✓ resume inmediato';
112
+ const s = Math.round(ms / 1000);
113
+ const h = Math.floor(s / 3600);
114
+ const m = Math.floor((s % 3600) / 60);
115
+ const sec = s % 60;
116
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
117
+ }
118
+
119
+ async function refreshStandby() {
120
+ try {
121
+ const r = await api('/api/standby');
122
+ _standbyData = r || { sessions: [] };
123
+ _renderStandbyBanner();
124
+ } catch { /* silencioso — endpoint nuevo, podría no existir en versiones viejas */ }
125
+ }
126
+
127
+ function _renderStandbyBanner() {
128
+ let banner = document.getElementById('standby-banner');
129
+ const sessions = _standbyData.sessions || [];
130
+ if (sessions.length === 0) {
131
+ if (banner) banner.remove();
132
+ return;
133
+ }
134
+ if (!banner) {
135
+ banner = document.createElement('div');
136
+ banner.id = 'standby-banner';
137
+ 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';
138
+ const app = document.getElementById('app');
139
+ if (app?.parentNode) app.parentNode.insertBefore(banner, app);
140
+ }
141
+ const now = Date.now();
142
+ banner.innerHTML = `
143
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
144
+ <strong>💤 ${sessions.length} sesión(es) hibernadas:</strong>
145
+ ${sessions.map((s) => {
146
+ const ms = new Date(s.cooldownUntil).getTime() - now;
147
+ const label = _fmtCountdown(ms);
148
+ const reason = s.reason || '?';
149
+ const planInfo = s.planId ? ` · ${s.planId}${s.huId ? '/' + s.huId : ''}` : '';
150
+ 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>`;
151
+ }).join('')}
152
+ <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>
153
+ </div>
154
+ `;
155
+ }
156
+
157
+ function _tickStandbyCountdowns() {
158
+ const banner = document.getElementById('standby-banner');
159
+ if (!banner) return;
160
+ const now = Date.now();
161
+ for (const chip of banner.querySelectorAll('[data-cooldown]')) {
162
+ const target = new Date(chip.getAttribute('data-cooldown')).getTime();
163
+ const span = chip.querySelector('.standby-countdown');
164
+ if (span) span.textContent = _fmtCountdown(target - now);
165
+ }
166
+ }
167
+
168
+ function startStandbyPolling() {
169
+ if (_standbyPollTimer || _standbyTickTimer) return;
170
+ refreshStandby();
171
+ _standbyPollTimer = setInterval(refreshStandby, 30_000);
172
+ _standbyTickTimer = setInterval(_tickStandbyCountdowns, 1000);
173
+ }
174
+
175
+ /**
176
+ * Trigger a full re-scan of disk data (hu-stories + sessions).
177
+ * Called on page load and via the sync button.
178
+ */
179
+ async function triggerSync() {
180
+ try {
181
+ await fetch('/api/sync', { method: 'POST' });
182
+ } catch { /* ignore — board may not support sync yet */ }
183
+ }
184
+
185
+ // ---- Human-friendly story IDs ----
186
+ //
187
+ // Internal story IDs look like
188
+ // home_manu_ws_ai_linux-assistant-orchestrator::hu_plan-20260424182640-4icc_003
189
+ // which is fine for disk and logs but terrible for humans. The UI shows a
190
+ // short form like `lao-003` (project initials + HU sequence) so the user
191
+ // can say "take a look at `lao-003`" instead of pasting the 82-char blob.
192
+ // The full ID stays accessible as a tooltip (and in the underlying data-id).
193
+
194
+ const projectInitialsCache = {};
195
+ const projectNameCache = {};
196
+
197
+ /**
198
+ * Humanise a slug-ish project id so the board can show
199
+ * "Linux Assistant Orchestrator" instead of
200
+ * "home_manu_ws_ai_linux-assistant-orchestrator" in the header.
201
+ *
202
+ * Strategy: take the last slash/underscore-separated segment (that's the
203
+ * project folder name), split it on dashes, drop short fragments and
204
+ * title-case the rest.
205
+ */
206
+ function humaniseProjectName(id) {
207
+ if (!id || typeof id !== 'string') return id || '';
208
+ const tail = id.split(/[/_]/).filter(Boolean).pop() || id;
209
+ const words = tail
210
+ .split(/[\s\-]+/)
211
+ .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
212
+ .filter((w) => w.length > 0);
213
+ if (words.length === 0) return id;
214
+ return words.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join(' ');
215
+ }
216
+
217
+ /**
218
+ * Derive initials from a project name. Splits on non-word characters,
219
+ * drops single-letter fragments ("a", "x") to avoid gibberish, keeps
220
+ * short-but-meaningful ones ("ai", "ui"), lowercases, caps to 5 chars.
221
+ *
222
+ * Examples:
223
+ * "Linux Assistant Orchestrator" → "lao"
224
+ * "AI Linux Assistant Orchestrator" → "alao"
225
+ * "karajan-code" → "kc"
226
+ */
227
+ function deriveInitialsFromName(name) {
228
+ const words = String(name || '')
229
+ .split(/[\s\-_/]+/)
230
+ .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
231
+ .filter((w) => w.length > 1);
232
+ return words.map((w) => w[0].toLowerCase()).join('').slice(0, 5) || 'kj';
233
+ }
234
+
235
+ /**
236
+ * Fetch-and-cache a project's metadata. Populates initialsCache and
237
+ * nameCache in one trip so neither renderStoryCard nor the section
238
+ * header has to await again. Falls back to the project id on any
239
+ * failure so the UI never blocks on a network error.
240
+ * @param {string} projectId
241
+ * @returns {Promise<{ initials: string, name: string }>}
242
+ */
243
+ async function resolveProjectMeta(projectId) {
244
+ if (!projectId) return { initials: 'kj', name: '' };
245
+ if (projectInitialsCache[projectId]) {
246
+ return { initials: projectInitialsCache[projectId], name: projectNameCache[projectId] };
247
+ }
248
+ try {
249
+ const project = await api(`/api/projects/${encodeURIComponent(projectId)}`);
250
+ const rawName = project.name || humaniseProjectName(projectId);
251
+ const initials = deriveInitialsFromName(rawName);
252
+ projectInitialsCache[projectId] = initials;
253
+ projectNameCache[projectId] = rawName;
254
+ return { initials, name: rawName };
255
+ } catch {
256
+ const fallbackName = humaniseProjectName(projectId);
257
+ projectInitialsCache[projectId] = deriveInitialsFromName(fallbackName);
258
+ projectNameCache[projectId] = fallbackName;
259
+ return { initials: projectInitialsCache[projectId], name: fallbackName };
260
+ }
261
+ }
262
+
263
+ /** Back-compat helper kept so existing callers don't break. */
264
+ async function resolveProjectInitials(projectId) {
265
+ const meta = await resolveProjectMeta(projectId);
266
+ return meta.initials;
267
+ }
268
+
269
+ /**
270
+ * Build the human-facing short ID for a story card.
271
+ * Example: `lao-003` for `home_…::hu_plan-20260424182640-4icc_003`.
272
+ * @param {object} story
273
+ * @param {string} initials
274
+ * @returns {string}
275
+ */
276
+ function shortStoryId(story, initials) {
277
+ const id = story?.id || '';
278
+ // Match `_<digits>` at the tail of the ID (with or without a trailing
279
+ // namespace part). This covers `…_003` as well as `…_0012`.
280
+ const m = /_(\d+)(?!.*\d)/.exec(id);
281
+ const num = m ? m[1] : '?';
282
+ return `${initials || 'kj'}-${num}`;
283
+ }
284
+
285
+ // ---- Utility Functions ----
286
+
287
+ /**
288
+ * Returns relative time string (e.g., "2 min ago").
289
+ * @param {string} dateStr - ISO date string
290
+ * @returns {string}
291
+ */
292
+ function timeAgo(dateStr) {
293
+ if (!dateStr) return '';
294
+ const diff = Date.now() - new Date(dateStr).getTime();
295
+ const seconds = Math.floor(diff / 1000);
296
+ if (seconds < 60) return `${seconds}s ago`;
297
+ const minutes = Math.floor(seconds / 60);
298
+ if (minutes < 60) return `${minutes} min ago`;
299
+ const hours = Math.floor(minutes / 60);
300
+ if (hours < 24) return `${hours}h ago`;
301
+ const days = Math.floor(hours / 24);
302
+ return `${days}d ago`;
303
+ }
304
+
305
+ /**
306
+ * Formats milliseconds to human readable duration.
307
+ * @param {number | null} ms
308
+ * @returns {string}
309
+ */
310
+ function formatDuration(ms) {
311
+ if (!ms) return '--';
312
+ const totalSec = Math.floor(ms / 1000);
313
+ const min = Math.floor(totalSec / 60);
314
+ const sec = totalSec % 60;
315
+ if (min === 0) return `${sec}s`;
316
+ return `${min}m ${sec.toString().padStart(2, '0')}s`;
317
+ }
318
+
319
+ /**
320
+ * Returns a quality score CSS class based on value.
321
+ * @param {number | null} score
322
+ * @param {number} max
323
+ * @returns {string}
324
+ */
325
+ function scoreClass(score, max = 60) {
326
+ if (score === null || score === undefined) return '';
327
+ const pct = score / max;
328
+ if (pct >= 0.7) return 'story-card__score--good';
329
+ if (pct >= 0.4) return 'story-card__score--ok';
330
+ return 'story-card__score--bad';
331
+ }
332
+
333
+ /**
334
+ * Generates quality bar HTML segments.
335
+ * @param {number | null} score
336
+ * @param {number} max
337
+ * @returns {string}
338
+ */
339
+ function qualityBar(score, max = 60) {
340
+ if (score === null || score === undefined) return '';
341
+ const filled = Math.round((score / max) * 10);
342
+ let html = '<span class="quality-bar">';
343
+ for (let i = 0; i < 10; i++) {
344
+ html += `<span class="quality-bar__segment${i < filled ? ' quality-bar__segment--filled' : ''}"></span>`;
345
+ }
346
+ html += '</span>';
347
+ return html;
348
+ }
349
+
350
+ /**
351
+ * Escapes HTML entities.
352
+ * @param {string} str
353
+ * @returns {string}
354
+ */
355
+ function esc(str) {
356
+ if (!str) return '';
357
+ const el = document.createElement('span');
358
+ el.textContent = str;
359
+ return el.innerHTML;
360
+ }
361
+
362
+ // is_test toggle visuals (KJC-TSK-0371). The icon and tooltip
363
+ // reflect the user's explicit override OR the default heuristic
364
+ // when none is set. Heuristic mirrors ephemeral-cleaner.js so the
365
+ // UI never lies about what the cleaner will actually do.
366
+ const EPHEMERAL_HEURISTIC_RE = /^(auto-)?(tmp_|test_|demo_|kj-test-)/i;
367
+ function isTestIcon(p) {
368
+ if (p.is_test === 1) return '🧪'; // user marked
369
+ if (p.is_test === 0) return '📌'; // user pinned
370
+ return EPHEMERAL_HEURISTIC_RE.test(p.id || '') ? '🧪?' : '·'; // heuristic
371
+ }
372
+ function isTestTitle(p) {
373
+ if (p.is_test === 1) return 'Marked as test — auto-cleans 24h after last activity. Click to pin.';
374
+ if (p.is_test === 0) return 'Pinned — never auto-cleans. Click to clear.';
375
+ if (EPHEMERAL_HEURISTIC_RE.test(p.id || '')) {
376
+ return 'Looks ephemeral by id heuristic — will auto-clean after 24h. Click to pin.';
377
+ }
378
+ return 'Real project — never auto-cleans. Click to mark as test.';
379
+ }
380
+
381
+ /**
382
+ * Truncates text to a max length.
383
+ * @param {string} text
384
+ * @param {number} max
385
+ * @returns {string}
386
+ */
387
+ function truncate(text, max = 100) {
388
+ if (!text) return '';
389
+ return text.length > max ? text.slice(0, max) + '...' : text;
390
+ }
391
+
392
+ // ---- Render Functions ----
393
+
394
+ /**
395
+ * Renders the dashboard view with global stats and project cards.
396
+ */
397
+ async function renderDashboard() {
398
+ const app = document.getElementById('app');
399
+ app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading dashboard...</p></div>';
400
+
401
+ try {
402
+ const [stats, projects] = await Promise.all([
403
+ api('/api/dashboard'),
404
+ api('/api/projects'),
405
+ ]);
406
+
407
+ const certPct = stats.total_stories > 0
408
+ ? Math.round((stats.certified_stories / stats.total_stories) * 100)
409
+ : 0;
410
+
411
+ app.innerHTML = `
412
+ <div class="stats-grid">
413
+ <div class="stat-card">
414
+ <div class="stat-card__value">${stats.total_stories}</div>
415
+ <div class="stat-card__label">Total Stories</div>
416
+ </div>
417
+ <div class="stat-card">
418
+ <div class="stat-card__value stat-card__value--green">${stats.certified_stories} (${certPct}%)</div>
419
+ <div class="stat-card__label">Certified</div>
420
+ </div>
421
+ <div class="stat-card">
422
+ <div class="stat-card__value stat-card__value--yellow">${stats.pending_stories}</div>
423
+ <div class="stat-card__label">Pending</div>
424
+ </div>
425
+ <div class="stat-card">
426
+ <div class="stat-card__value stat-card__value--purple">${stats.avg_quality !== null ? stats.avg_quality + '/60' : '--'}</div>
427
+ <div class="stat-card__label">Avg Quality</div>
428
+ </div>
429
+ <div class="stat-card">
430
+ <div class="stat-card__value">${stats.total_sessions}</div>
431
+ <div class="stat-card__label">Sessions</div>
432
+ </div>
433
+ <div class="stat-card">
434
+ <div class="stat-card__value stat-card__value--green">${stats.approved_sessions}</div>
435
+ <div class="stat-card__label">Approved</div>
436
+ </div>
437
+ <div class="stat-card">
438
+ <div class="stat-card__value stat-card__value--purple">${stats.total_projects}</div>
439
+ <div class="stat-card__label">Projects</div>
440
+ </div>
441
+ </div>
442
+
443
+ <div class="section-header">
444
+ <span class="section-header__title">Projects</span>
445
+ <span class="section-header__count">${projects.length} projects</span>
446
+ </div>
447
+
448
+ ${projects.length === 0 ? renderEmptyState() : `
449
+ <div class="projects-grid">
450
+ ${projects.map((p) => `
451
+ <div class="project-card">
452
+ <button class="project-card__delete" title="Delete project (cascade)" data-project-id="${esc(p.id)}" data-project-name="${esc(p.name || p.id)}">🗑️</button>
453
+ <button class="project-card__is-test" title="${esc(isTestTitle(p))}" data-project-id="${esc(p.id)}" data-is-test="${p.is_test === null || p.is_test === undefined ? '' : p.is_test}">${isTestIcon(p)}</button>
454
+ <div class="project-card__body" onclick="selectProject('${esc(p.id)}')">
455
+ <div class="project-card__name">${esc(p.name || p.id)}</div>
456
+ <div class="project-card__stats">
457
+ <div class="project-card__stat">
458
+ <div class="project-card__stat-value">${p.story_count || 0}</div>
459
+ <div class="project-card__stat-label">Stories</div>
460
+ </div>
461
+ <div class="project-card__stat">
462
+ <div class="project-card__stat-value">${p.certified_count || 0}</div>
463
+ <div class="project-card__stat-label">Certified</div>
464
+ </div>
465
+ <div class="project-card__stat">
466
+ <div class="project-card__stat-value">${p.session_count || 0}</div>
467
+ <div class="project-card__stat-label">Sessions</div>
468
+ </div>
469
+ </div>
470
+ <div class="project-card__activity">Last activity: ${timeAgo(p.last_activity)}</div>
471
+ </div>
472
+ </div>
473
+ `).join('')}
474
+ </div>
475
+ `}
476
+ `;
477
+ } catch (err) {
478
+ app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading dashboard</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Renders the kanban board view.
484
+ */
485
+ async function renderBoard() {
486
+ const app = document.getElementById('app');
487
+ app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading board...</p></div>';
488
+
489
+ try {
490
+ // No project selected → render the project picker instead of
491
+ // mixing every project's HUs in one kanban. The user's
492
+ // complaint: "if no project is selected, show me the list of
493
+ // projects". A mixed kanban hides which HU belongs to which
494
+ // project and is borderline useless on a multi-project setup.
495
+ if (!selectedProject) {
496
+ await renderProjectPicker();
497
+ return;
498
+ }
499
+
500
+ const stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
501
+
502
+ // Pre-resolve project initials + name for every distinct project_id in
503
+ // the fetched stories so `renderStoryCard` is synchronous and the header
504
+ // can show the human-friendly project name (e.g. "Linux Assistant
505
+ // Orchestrator" instead of the raw slug). Cached globally → at most one
506
+ // API round-trip per project over the session.
507
+ const uniqueProjectIds = [...new Set(stories.map((s) => s.project_id))];
508
+ await Promise.all(uniqueProjectIds.map(resolveProjectMeta));
509
+ if (selectedProject && !projectNameCache[selectedProject]) {
510
+ await resolveProjectMeta(selectedProject);
511
+ }
512
+ const projectDisplayName = selectedProject
513
+ ? (projectNameCache[selectedProject] || humaniseProjectName(selectedProject))
514
+ : '';
515
+
516
+ // KJC-TSK-0403: status/result ortogonal. 3 columnas (Pending /
517
+ // Running / Done) en vez de 4. Las HUs que fallaron NO van a una
518
+ // columna "Failed" — vuelven a Pending con result=fail y badge ✗.
519
+ // status=failed legacy se trata como pending (migración lazy).
520
+ const columns = {
521
+ pending: stories.filter((s) =>
522
+ ['pending', 'certified', 'needs_context', 'blocked', 'failed'].includes(s.status)
523
+ ),
524
+ running: stories.filter((s) => ['coding', 'reviewing'].includes(s.status)),
525
+ done: stories.filter((s) => s.status === 'done'),
526
+ };
527
+
528
+ if (stories.length === 0) {
529
+ app.innerHTML = renderEmptyState();
530
+ return;
531
+ }
532
+
533
+ // "Run plan" bulk action: visible when a project is selected AND at
534
+ // least one HU is still awaiting execution AND nothing's currently
535
+ // running (to avoid accidentally launching a second pipeline over a
536
+ // live one). Replaces the old "Mark as certified" button — the
537
+ // intermediate "certified" state was noise from the user's POV.
538
+ const awaitingCount = columns.pending.length;
539
+ const runningCount = columns.running.length;
540
+ const canRun = Boolean(selectedProject) && awaitingCount > 0 && runningCount === 0;
541
+ const isRunning = runningCount > 0;
542
+
543
+ // KJC-TSK-0403: 3 canonical lanes. Failed eliminado — HUs con
544
+ // result=fail aparecen en Pending con badge ✗.
545
+ const visibleColumns = [
546
+ { title: 'Pending', cls: 'pending', rows: columns.pending },
547
+ { title: 'Running', cls: 'running', rows: columns.running },
548
+ { title: 'Done', cls: 'done', rows: columns.done },
549
+ ];
550
+
551
+ app.innerHTML = `
552
+ <div class="section-header">
553
+ <span class="section-header__title" title="${selectedProject ? esc(selectedProject) : ''}">Story Board${selectedProject ? ` - ${esc(projectDisplayName)}` : ''}</span>
554
+ ${selectedProject ? `
555
+ <button class="control-btn project-rename-btn"
556
+ style="background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:0.9rem;padding:2px 6px;"
557
+ title="Renombrar este proyecto"
558
+ onclick="event.stopPropagation(); window.renameProjectModal('${esc(selectedProject)}', '${esc(projectDisplayName.replace(/'/g, '&#39;'))}');">✎</button>
559
+ ` : ''}
560
+ <span class="section-header__count">${stories.length} stories</span>
561
+ ${isRunning ? `
562
+ <button id="running-badge-btn" class="section-header__badge"
563
+ 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;"
564
+ title="Abrir el log de la HU en marcha">
565
+ ⚙ ${runningCount} running…
566
+ </button>
567
+ <button id="stop-run-btn" class="control-btn"
568
+ 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;"
569
+ title="Abortar todos los kj run en marcha de este proyecto (SIGTERM con escalado a SIGKILL tras 5s)">
570
+ ⏹ Stop
571
+ </button>
572
+ ` : ''}
573
+ ${lastOpenedLog ? `
574
+ <button class="control-btn" id="view-log-btn"
575
+ 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;"
576
+ title="Re-open the log of the most recent run/command">
577
+ 📜 View log
578
+ </button>
579
+ ` : ''}
580
+ ${canRun ? `
581
+ <button class="control-btn" id="run-plan-btn"
582
+ 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;"
583
+ title="Launch kj run --plan over every plan in this project">
584
+ ▶ Run plan (${awaitingCount} HU${awaitingCount === 1 ? '' : 's'})
585
+ </button>
586
+ ` : ''}
587
+ </div>
588
+ <div id="preflight-panel" class="preflight-panel" style="margin:8px 0;">
589
+ <div class="preflight-panel__loading" style="font-size:0.8rem;color:var(--text-muted);padding:6px 10px;">
590
+ Comprobando estado del proyecto…
591
+ </div>
592
+ </div>
593
+ <div id="plan-rollup-banner"></div>
594
+ <div class="kanban">
595
+ ${visibleColumns.map((c) => renderKanbanColumn(c.title, c.cls, c.rows)).join('')}
596
+ </div>
597
+ `;
598
+
599
+ if (canRun) {
600
+ document.getElementById('run-plan-btn').addEventListener('click', async () => {
601
+ // Pre-run safety: ask preflight if anything risky is missing
602
+ // (no remote, dirty tree, missing agents, etc.). The modal
603
+ // shows blockers + warnings in plain Spanish and the user
604
+ // can cancel or proceed anyway. If everything is green we
605
+ // skip the modal and run straight away.
606
+ const ok = await confirmRunWithPreflight(selectedProject);
607
+ if (!ok) return;
608
+ await runProject(selectedProject);
609
+ });
610
+ }
611
+ // Preflight panel is rendered AFTER the kanban so we can fetch
612
+ // it without blocking the initial paint. Replaces a placeholder
613
+ // div in-place (no full re-render).
614
+ renderPreflightPanel(selectedProject).catch(() => {});
615
+ // PR3: render the plan-level rollup banner if any plan in this
616
+ // project has a stamped outcome. Non-blocking — if it fails the
617
+ // user just doesn't see the banner.
618
+ renderPlanRollup(selectedProject).catch(() => {});
619
+ const viewLogBtn = document.getElementById('view-log-btn');
620
+ if (viewLogBtn && lastOpenedLog) {
621
+ viewLogBtn.addEventListener('click', () => openGenericLogPanel({
622
+ id: lastOpenedLog.id,
623
+ label: lastOpenedLog.label,
624
+ tailUrl: lastOpenedLog.tailUrl,
625
+ }));
626
+ }
627
+ // Wire the yellow "⚙ N running…" badge so it opens the live log of the
628
+ // first running HU. Without this the badge was decorative — and if the
629
+ // run was started before the current page load (refresh / new browser
630
+ // session) `lastOpenedLog` is null, so the "📜 View log" button isn't
631
+ // even rendered. Then the user has no way to see the live output.
632
+ //
633
+ // For a single-HU run the log lives at:
634
+ // ~/.karajan/hu-board-runs/<planId>--hu-<localHuId>.log
635
+ // which the /api/runs/:commandId/log endpoint serves when commandId
636
+ // matches the basename. The composite id stored in SQLite is
637
+ // "<projectId>::<localHuId>", so we strip the prefix to derive
638
+ // localHuId and concatenate with plan_id (already on the story row).
639
+ const runningBadgeBtn = document.getElementById('running-badge-btn');
640
+ if (runningBadgeBtn) {
641
+ runningBadgeBtn.addEventListener('click', async () => {
642
+ const running = stories.find((s) => s.status === 'coding' || s.status === 'reviewing');
643
+ if (!running) return;
644
+ const planId = running.plan_id;
645
+ if (!planId) {
646
+ await showError('Esta HU no tiene plan_id asociado, no puedo localizar su log.', { title: 'Sin log' });
647
+ return;
648
+ }
649
+ const localHuId = running.id.includes('::') ? running.id.split('::').pop() : running.id;
650
+ const logBasename = `${planId}--hu-${localHuId}`;
651
+ const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
652
+ lastOpenedLog = { id: logBasename, label: `HU ${localHuId}`, tailUrl };
653
+ openGenericLogPanel({ id: logBasename, label: `HU ${localHuId}`, tailUrl });
654
+ });
655
+ }
656
+
657
+ // KJC-TSK-0396: botón ⏹ Stop. Abortar todos los kj run vivos del
658
+ // proyecto. Si hay múltiples planes corriendo (caso raro pero
659
+ // posible), itera sobre cada planId único de las HUs running.
660
+ const stopRunBtn = document.getElementById('stop-run-btn');
661
+ if (stopRunBtn) {
662
+ stopRunBtn.addEventListener('click', async () => {
663
+ const runningHus = stories.filter((s) => s.status === 'coding' || s.status === 'reviewing');
664
+ const planIds = [...new Set(runningHus.map((s) => s.plan_id).filter(Boolean))];
665
+ if (planIds.length === 0) {
666
+ await showError('No hay plan_id asociado a las HUs en marcha.', { title: 'Sin runs' });
667
+ return;
668
+ }
669
+ const ok = await showConfirm(
670
+ `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?`,
671
+ { title: 'Abortar kj run', okLabel: 'Stop', cancelLabel: 'Cancelar', destructive: true }
672
+ );
673
+ if (!ok) return;
674
+ const summaries = [];
675
+ for (const planId of planIds) {
676
+ try {
677
+ const res = await fetch(`/api/runs/${encodeURIComponent(planId)}/stop`, {
678
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
679
+ });
680
+ const body = await res.json();
681
+ if (!res.ok) summaries.push(`${planId}: error ${body.error || res.status}`);
682
+ else summaries.push(`${planId}: ${body.stopped} SIGTERM, ${body.killed} SIGKILL, ${body.hu_reset_count || 0} HUs → pending`);
683
+ } catch (err) {
684
+ summaries.push(`${planId}: ${err.message}`);
685
+ }
686
+ }
687
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
688
+ await renderBoard();
689
+ if (summaries.some((s) => s.includes('SIGKILL') && !s.startsWith('0 ')) && summaries.some((s) => /\b[1-9]\d* SIGKILL/.test(s))) {
690
+ await showError(
691
+ `Algún proceso no respondió a SIGTERM en 5s y fue forzado con SIGKILL:\n\n${summaries.join('\n')}`,
692
+ { title: 'Forzado con SIGKILL' }
693
+ );
694
+ }
695
+ });
696
+ }
697
+ } catch (err) {
698
+ 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>`;
699
+ }
700
+ }
701
+
702
+ // ---- DAG view ----
703
+ //
704
+ // The kanban answers "what's the status of each HU"; the graph answers
705
+ // "what depends on what, and what could run in parallel". It's a
706
+ // leveled topological layout drawn as vanilla SVG — no Cytoscape /
707
+ // D3 / Mermaid dependency because the board ships as a local
708
+ // dashboard and every KB of JS saved is one less thing that can
709
+ // mis-fetch behind a corporate proxy. The layout is basic but honest:
710
+ // roots at the top, children below, siblings side-by-side, curved
711
+ // bezier arrows. Good enough for plans up to ~30 HUs — beyond that
712
+ // we'd want a real DAG library, but that's a problem for another day.
713
+
714
+ const DAG_NODE_W = 170;
715
+ const DAG_NODE_H = 62;
716
+ const DAG_H_GAP = 28;
717
+ const DAG_V_GAP = 72;
718
+ const DAG_PAD = 32;
719
+
720
+ /**
721
+ * Resolve `hu.blocked_by` HU-ids (e.g. "plan-xxx_001") to full story
722
+ * ids ("<project>::plan-xxx_001") so we can join by the board's row id.
723
+ * Ids that don't resolve are dropped silently — they usually signal a
724
+ * cross-plan dep which we don't render.
725
+ */
726
+ function resolveBlockedBy(rawIds, storyIds) {
727
+ if (!Array.isArray(rawIds) || rawIds.length === 0) return [];
728
+ const resolved = [];
729
+ for (const raw of rawIds) {
730
+ const match = storyIds.find((id) => id.endsWith(`::${raw}`)) || (storyIds.includes(raw) ? raw : null);
731
+ if (match) resolved.push(match);
732
+ }
733
+ return resolved;
734
+ }
735
+
736
+ /**
737
+ * Assign each story a level = 1 + max(level of its deps). Roots get 0.
738
+ * Cycle-guarded with a visited set — KJ's planner shouldn't emit one
739
+ * but a hand-edited plan JSON might.
740
+ */
741
+ function computeDagLevels(stories) {
742
+ const storyIds = stories.map((s) => s.id);
743
+ const byId = new Map();
744
+ for (const s of stories) {
745
+ const rawBlockedBy = s.blocked_by ? JSON.parse(s.blocked_by) : [];
746
+ byId.set(s.id, {
747
+ ...s,
748
+ resolvedBlockedBy: resolveBlockedBy(rawBlockedBy, storyIds),
749
+ });
750
+ }
751
+
752
+ const levels = new Map();
753
+ const visiting = new Set();
754
+ function levelOf(id) {
755
+ if (levels.has(id)) return levels.get(id);
756
+ if (visiting.has(id)) return 0; // cycle → break
757
+ const s = byId.get(id);
758
+ if (!s || s.resolvedBlockedBy.length === 0) { levels.set(id, 0); return 0; }
759
+ visiting.add(id);
760
+ const l = 1 + Math.max(...s.resolvedBlockedBy.map(levelOf));
761
+ visiting.delete(id);
762
+ levels.set(id, l);
763
+ return l;
764
+ }
765
+ for (const id of byId.keys()) levelOf(id);
766
+
767
+ const byLevel = new Map();
768
+ for (const [id, lvl] of levels) {
769
+ if (!byLevel.has(lvl)) byLevel.set(lvl, []);
770
+ byLevel.get(lvl).push(id);
771
+ }
772
+
773
+ return { byId, levels, byLevel };
774
+ }
775
+
776
+ /**
777
+ * Render the graph view. If no project is selected we show a prompt
778
+ * — drawing every project's HUs in one DAG is visual garbage. If the
779
+ * current project has no stories or no blocked_by edges, we show a
780
+ * friendly empty state explaining what the view is for.
781
+ */
782
+ async function renderGraph() {
783
+ const app = document.getElementById('app');
784
+ app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading graph…</p></div>';
785
+
786
+ if (!selectedProject) {
787
+ app.innerHTML = renderEmptyState(
788
+ 'Pick a project first',
789
+ 'The dependency graph is per-project. Choose one in the dropdown above and I will draw its HU DAG.'
790
+ );
791
+ return;
792
+ }
793
+
794
+ try {
795
+ const stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
796
+ if (stories.length === 0) {
797
+ app.innerHTML = renderEmptyState('No HUs in this project', 'Run kj plan to generate some.');
798
+ return;
799
+ }
800
+ // Preload initials so node labels can use short ids synchronously.
801
+ await resolveProjectMeta(selectedProject);
802
+ const projectDisplayName = projectNameCache[selectedProject] || humaniseProjectName(selectedProject);
803
+ const initials = projectInitialsCache[selectedProject] || 'kj';
804
+
805
+ const { byId, byLevel } = computeDagLevels(stories);
806
+ const maxLevel = Math.max(0, ...Array.from(byLevel.keys()));
807
+ const widestLevel = Math.max(0, ...Array.from(byLevel.values()).map((ids) => ids.length));
808
+ const width = widestLevel * (DAG_NODE_W + DAG_H_GAP) + DAG_PAD * 2;
809
+ const height = (maxLevel + 1) * (DAG_NODE_H + DAG_V_GAP) + DAG_PAD * 2;
810
+
811
+ // Assign x,y to each node. Within a level we distribute horizontally
812
+ // evenly; we make no effort to minimise edge crossings (the naive
813
+ // layout is fine for ~20 HUs, which is the realistic ceiling for a
814
+ // single plan).
815
+ const positions = new Map();
816
+ for (const [lvl, ids] of byLevel) {
817
+ const y = DAG_PAD + lvl * (DAG_NODE_H + DAG_V_GAP);
818
+ const rowW = ids.length * DAG_NODE_W + (ids.length - 1) * DAG_H_GAP;
819
+ const xStart = (width - rowW) / 2;
820
+ ids.forEach((id, i) => {
821
+ positions.set(id, { x: xStart + i * (DAG_NODE_W + DAG_H_GAP), y });
822
+ });
823
+ }
824
+
825
+ const edges = [];
826
+ for (const s of byId.values()) {
827
+ for (const parentId of s.resolvedBlockedBy) {
828
+ if (positions.has(parentId) && positions.has(s.id)) {
829
+ edges.push({ from: parentId, to: s.id });
830
+ }
831
+ }
832
+ }
833
+
834
+ const anyEdges = edges.length > 0;
835
+
836
+ const svg = `
837
+ <svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"
838
+ xmlns="http://www.w3.org/2000/svg"
839
+ style="display:block;margin:0 auto;max-width:100%;overflow:visible">
840
+ <defs>
841
+ <marker id="dag-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
842
+ <path d="M0,0 L7,4 L0,8 Z" fill="var(--text-muted)"/>
843
+ </marker>
844
+ </defs>
845
+ ${edges.map((e) => {
846
+ const p1 = positions.get(e.from);
847
+ const p2 = positions.get(e.to);
848
+ const x1 = p1.x + DAG_NODE_W / 2;
849
+ const y1 = p1.y + DAG_NODE_H;
850
+ const x2 = p2.x + DAG_NODE_W / 2;
851
+ const y2 = p2.y;
852
+ const cy = (y1 + y2) / 2;
853
+ return `<path d="M${x1},${y1} C${x1},${cy} ${x2},${cy} ${x2},${y2}"
854
+ stroke="var(--text-muted)" stroke-width="1.5" fill="none"
855
+ marker-end="url(#dag-arrow)" opacity="0.7"/>`;
856
+ }).join('')}
857
+ ${Array.from(byId.values()).map((s) => {
858
+ const p = positions.get(s.id);
859
+ const shortId = shortStoryId(s, initials);
860
+ const title = truncate(s.title || s.original_text || '', 26);
861
+ return `
862
+ <g transform="translate(${p.x},${p.y})"
863
+ class="dag-node status--${s.status}"
864
+ style="cursor:pointer"
865
+ onclick="showStoryDetail('${esc(s.id)}')">
866
+ <title>${esc(s.id)}</title>
867
+ <rect width="${DAG_NODE_W}" height="${DAG_NODE_H}" rx="6"
868
+ fill="var(--bg-secondary)" stroke="var(--border)" stroke-width="1.2"/>
869
+ <rect width="4" height="${DAG_NODE_H}" rx="2" fill="currentColor"/>
870
+ <text x="12" y="20" font-size="11" font-weight="600" fill="var(--text)">
871
+ ${esc(shortId)}
872
+ </text>
873
+ <text x="${DAG_NODE_W - 10}" y="20" text-anchor="end" font-size="10"
874
+ fill="var(--text-muted)">${esc(s.status)}</text>
875
+ <text x="12" y="40" font-size="10.5" fill="var(--text-muted)">
876
+ ${esc(title)}
877
+ </text>
878
+ </g>
879
+ `;
880
+ }).join('')}
881
+ </svg>
882
+ `;
883
+
884
+ app.innerHTML = `
885
+ <div class="section-header">
886
+ <span class="section-header__title">Dependency graph — ${esc(projectDisplayName)}</span>
887
+ <span class="section-header__count">${stories.length} HUs${anyEdges ? ` · ${edges.length} dep${edges.length === 1 ? '' : 's'}` : ' · no dependencies declared'}</span>
888
+ </div>
889
+ <div style="padding:8px;overflow:auto;max-height:calc(100vh - 140px)">
890
+ ${svg}
891
+ </div>
892
+ ${!anyEdges ? `
893
+ <div style="padding:14px 20px;color:var(--text-muted);font-size:0.85rem;max-width:720px;margin:12px auto">
894
+ No HU in this plan declares <code>blocked_by</code>, so the DAG degenerates to a row of isolated nodes.
895
+ Add dependencies via <code>kj plan</code> on generation or the modal <em>Edit</em> form
896
+ (blocked_by editing is on the roadmap).
897
+ </div>
898
+ ` : ''}
899
+ `;
900
+ } catch (err) {
901
+ app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading graph</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
902
+ }
903
+ }
904
+
905
+ /**
906
+ * "Pick a project" view shown by the Board tab when nothing is
907
+ * selected. Lists every project that has at least one HU with the
908
+ * pending / running / done / failed counts — clicking takes the
909
+ * user into that project's kanban (`#board/<slug>`).
910
+ *
911
+ * Why this instead of an "All projects" merged kanban: HU short ids
912
+ * collide across projects (every project has a `*-001`), the column
913
+ * counts become meaningless, and "missing test contract" warnings
914
+ * apply to whatever-project so the user can't tell what to act on.
915
+ * Forcing a project pick before the kanban is the cheapest way to
916
+ * keep every other UI invariant honest.
917
+ */
918
+ async function renderProjectPicker() {
919
+ const app = document.getElementById('app');
920
+ const projects = await api('/api/projects');
921
+ // Pre-resolve human names so the rendered list matches what the
922
+ // header / dropdown show elsewhere.
923
+ await Promise.all(projects.map((p) => resolveProjectMeta(p.id)));
924
+
925
+ if (projects.length === 0) {
926
+ app.innerHTML = renderEmptyState(
927
+ 'No projects yet',
928
+ 'Run kj plan or click ⚡ in the header to generate one. The board will pick it up automatically.'
929
+ );
930
+ return;
931
+ }
932
+
933
+ // Per-project status counts come from /api/projects/:id/stories.
934
+ // One round-trip per project; cheap on a local SQLite-backed API.
935
+ const allStories = await Promise.all(
936
+ projects.map((p) => api(`/api/projects/${encodeURIComponent(p.id)}/stories`).then((s) => ({ id: p.id, stories: s })))
937
+ );
938
+ const byProject = new Map(allStories.map((entry) => [entry.id, entry.stories]));
939
+
940
+ function bucket(stories) {
941
+ const c = { pending: 0, running: 0, done: 0, failed: 0 };
942
+ for (const s of stories) {
943
+ if (['pending', 'certified', 'needs_context', 'blocked'].includes(s.status)) c.pending += 1;
944
+ else if (['coding', 'reviewing'].includes(s.status)) c.running += 1;
945
+ else if (s.status === 'done') c.done += 1;
946
+ else if (s.status === 'failed') c.failed += 1;
947
+ }
948
+ return c;
949
+ }
950
+
951
+ // Sort: most recent activity first. Falls back to alphabetic when
952
+ // last_activity is missing (older projects synced pre-#480).
953
+ const sorted = [...projects].sort((a, b) => {
954
+ const la = a.last_activity || '';
955
+ const lb = b.last_activity || '';
956
+ if (la && lb && la !== lb) return lb.localeCompare(la);
957
+ return (a.name || a.id).localeCompare(b.name || b.id);
958
+ });
959
+
960
+ app.innerHTML = `
961
+ <div class="section-header">
962
+ <span class="section-header__title">Story Board</span>
963
+ <span class="section-header__count">${sorted.length} project${sorted.length === 1 ? '' : 's'}</span>
964
+ </div>
965
+ <p style="padding:8px 4px 16px;color:var(--text-muted);font-size:0.9rem">
966
+ Pick a project to see its kanban. Use the project selector in the header to switch later.
967
+ </p>
968
+ <div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:14px">
969
+ ${sorted.map((p) => {
970
+ const counts = bucket(byProject.get(p.id) || []);
971
+ const total = counts.pending + counts.running + counts.done + counts.failed;
972
+ const name = projectNameCache[p.id] || p.name || humaniseProjectName(p.id);
973
+ // div en lugar de <button> para poder anidar el botón de
974
+ // delete (HTML no permite buttons anidados). role+tabindex
975
+ // mantienen la accesibilidad equivalente.
976
+ return `
977
+ <div role="button" tabindex="0" class="project-picker__card" data-project-id="${esc(p.id)}"
978
+ style="position:relative;text-align:left;display:flex;flex-direction:column;gap:8px;padding:14px 16px;
979
+ background:var(--bg-secondary);border:1px solid var(--border);
980
+ border-radius:var(--radius-sm);cursor:pointer;color:var(--text);
981
+ transition:border-color 120ms">
982
+ <button class="project-card__delete" title="Borrar proyecto (cascade)"
983
+ data-project-id="${esc(p.id)}" data-project-name="${esc(name)}">🗑️</button>
984
+ <div style="display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding-right:32px">
985
+ <strong style="font-size:0.95rem">${esc(name)}</strong>
986
+ <span style="font-size:0.75rem;color:var(--text-muted)">${total} HU${total === 1 ? '' : 's'}</span>
987
+ </div>
988
+ <div style="display:flex;flex-wrap:wrap;gap:6px;font-size:0.78rem">
989
+ ${counts.pending > 0 ? `<span title="Pending" style="padding:2px 8px;border-radius:var(--radius-sm);background:var(--bg-primary)">⏳ ${counts.pending}</span>` : ''}
990
+ ${counts.running > 0 ? `<span title="Running" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(234,179,8,0.18);color:#facc15">⚙ ${counts.running}</span>` : ''}
991
+ ${counts.done > 0 ? `<span title="Done" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(74,222,128,0.18);color:#4ade80">✓ ${counts.done}</span>` : ''}
992
+ ${counts.failed > 0 ? `<span title="Failed" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(248,113,113,0.18);color:#f87171">✗ ${counts.failed}</span>` : ''}
993
+ ${total === 0 ? `<span style="color:var(--text-muted)">empty</span>` : ''}
994
+ </div>
995
+ <div style="font-family:var(--font-mono, monospace);font-size:0.7rem;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.id)}">
996
+ ${esc(p.id)}
997
+ </div>
998
+ </div>
999
+ `;
1000
+ }).join('')}
1001
+ </div>
1002
+ `;
1003
+
1004
+ app.querySelectorAll('.project-picker__card').forEach((btn) => {
1005
+ const enter = () => {
1006
+ const id = btn.dataset.projectId;
1007
+ // Use the existing route so back/forward work and the dropdown
1008
+ // syncs via handleRoute().
1009
+ window.location.hash = `board/${encodeURIComponent(id)}`;
1010
+ };
1011
+ btn.addEventListener('click', (e) => {
1012
+ // Click sobre el botón delete anidado — el handler global ya
1013
+ // hace stopPropagation + preventDefault, pero por defensa
1014
+ // ignoramos aquí cualquier click que venga de dentro de él.
1015
+ if (e.target.closest('.project-card__delete')) return;
1016
+ enter();
1017
+ });
1018
+ btn.addEventListener('keydown', (e) => {
1019
+ if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('.project-card__delete')) {
1020
+ e.preventDefault();
1021
+ enter();
1022
+ }
1023
+ });
1024
+ // Tiny hover affordance — no CSS file edit needed.
1025
+ btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--color-green)'; });
1026
+ btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--border)'; });
1027
+ });
1028
+ }
1029
+
1030
+ /**
1031
+ * Renders a single kanban column.
1032
+ * @param {string} title
1033
+ * @param {string} cssClass
1034
+ * @param {Array<object>} stories
1035
+ * @returns {string}
1036
+ */
1037
+ function renderKanbanColumn(title, cssClass, stories) {
1038
+ // Empty lanes render the header (so the user keeps the 4-column
1039
+ // mental map) but no body placeholder — "No stories" text on four
1040
+ // empty columns was noise on fresh plans.
1041
+ return `
1042
+ <div class="kanban__column kanban__column--${cssClass}" data-column="${cssClass}"${stories.length === 0 ? ' style="opacity:0.55"' : ''}>
1043
+ <div class="kanban__column-header">
1044
+ <span class="kanban__column-title">${title}</span>
1045
+ <span class="kanban__column-count" data-column-count>${stories.length}</span>
1046
+ </div>
1047
+ ${stories.map(renderStoryCard).join('')}
1048
+ </div>
1049
+ `;
1050
+ }
1051
+
1052
+ /**
1053
+ * Renders a story card for the kanban board.
1054
+ * @param {object} story
1055
+ * @returns {string}
1056
+ */
1057
+ function renderStoryCard(story) {
1058
+ const title = story.title || story.original_text || story.id;
1059
+ const antipatterns = story.antipatterns ? JSON.parse(story.antipatterns) : [];
1060
+ // Prefer denormalised counters stamped at sync time; fall back to
1061
+ // parsing the JSON blob for pre-migration rows so the card still shows
1062
+ // the AC count without a board DB nuke.
1063
+ const acCount = typeof story.ac_count === 'number'
1064
+ ? story.ac_count
1065
+ : (story.acceptance_criteria ? JSON.parse(story.acceptance_criteria).length : 0);
1066
+ const testCount = typeof story.test_count === 'number' ? story.test_count : 0;
1067
+ const blockedBy = story.blocked_by ? JSON.parse(story.blocked_by) : [];
1068
+ const initials = projectInitialsCache[story.project_id] || 'kj';
1069
+ const shortId = shortStoryId(story, initials);
1070
+
1071
+ // Human-readable dep list: `lao-001, lao-003` instead of the raw HU ids.
1072
+ const shortDep = (depId) => {
1073
+ const m = /_(\d+)(?!.*\d)/.exec(depId);
1074
+ return `${initials}-${m ? m[1] : '?'}`;
1075
+ };
1076
+
1077
+ // Tests-first flag: a HU with zero acceptance_tests has no contract
1078
+ // for the coder, so `Run plan` will refuse to execute it. Surface
1079
+ // this on the card so the user edits the HU before trying to run.
1080
+ const missingTestContract = testCount === 0 && ['pending', 'certified'].includes(story.status);
1081
+
1082
+ // PR4: per-HU ▶ Run button. Visible when:
1083
+ // - the HU is in a re-runnable lifecycle state (certified / failed /
1084
+ // pending / done — KJC-TSK-0394 step 2 adds `done` so the user can
1085
+ // replay a HU whose `result` was fail or partial without having to
1086
+ // "reset to pending" first) AND
1087
+ // - it has at least one acceptance test (otherwise the run would
1088
+ // refuse) AND
1089
+ // - it isn't currently coding/reviewing AND
1090
+ // - KJC-BUG-0048: it has no unresolved blocked_by deps. The card
1091
+ // already shows "⏳ waits for: ..." below the title; the ▶ button
1092
+ // must match that gating or the user can launch a HU whose deps
1093
+ // don't exist yet, hitting the missing-prereq path at runtime.
1094
+ const canRunHu = ['certified', 'failed', 'pending', 'done'].includes(story.status)
1095
+ && testCount > 0
1096
+ && !['coding', 'reviewing'].includes(story.status)
1097
+ && blockedBy.length === 0;
1098
+
1099
+ return `
1100
+ <div class="story-card" data-story-id="${esc(story.id)}" data-status="${esc(story.status || 'pending')}" onclick="showStoryDetail('${esc(story.id)}')">
1101
+ <div class="story-card__id" title="${esc(story.id)}">
1102
+ ${esc(shortId)}
1103
+ ${canRunHu ? `
1104
+ <button class="story-card__run-btn"
1105
+ 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;"
1106
+ title="Lanzar solo esta HU"
1107
+ onclick="event.stopPropagation(); window.runSingleHuFromCard('${esc(story.id)}', '${esc(title.replace(/'/g, '&#39;'))}');">▶</button>
1108
+ ` : ''}
1109
+ </div>
1110
+ <div class="story-card__title">${esc(truncate(title, 100))}</div>
1111
+ <div class="story-card__meta" style="gap:10px">
1112
+ ${acCount > 0 ? `<span title="${acCount} acceptance criteria">📋 ${acCount} AC${acCount === 1 ? '' : 's'}</span>` : ''}
1113
+ ${testCount > 0 ? `<span title="${testCount} acceptance tests declared">✅ ${testCount} test${testCount === 1 ? '' : 's'}</span>` : ''}
1114
+ ${story.spec_section ? `<span title="Implements SPEC ${esc(story.spec_section)}" style="font-family:var(--font-mono, monospace)">📖 §${esc(story.spec_section)}</span>` : ''}
1115
+ ${story.quality_total !== null ? `
1116
+ <span class="story-card__score ${scoreClass(story.quality_total)}" title="INVEST score">
1117
+ ${story.quality_total}/60 ${qualityBar(story.quality_total)}
1118
+ </span>
1119
+ ` : ''}
1120
+ </div>
1121
+ ${missingTestContract ? `
1122
+ <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.">
1123
+ ⚠ missing test contract
1124
+ </div>
1125
+ ` : ''}
1126
+ ${blockedBy.length > 0 ? `
1127
+ <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(', '))}">
1128
+ ⏳ waits for: ${blockedBy.map((d) => esc(shortDep(d))).join(', ')}
1129
+ </div>
1130
+ ` : (story.status === 'pending' || story.status === 'certified') && !missingTestContract ? `
1131
+ <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'">
1132
+ 🟢 ready to run
1133
+ </div>
1134
+ ` : ''}
1135
+ ${antipatterns.length > 0 ? `<div class="story-card__antipattern">${antipatterns.map((a) => esc(a)).join(', ')}</div>` : ''}
1136
+ <div class="story-card__meta" style="margin-top:6px">
1137
+ <span class="story-card__status status--${story.status}">${esc(story.status)}</span>
1138
+ ${renderResultBadge(computeEffectiveResult(story))}
1139
+ <span class="story-card__time">${timeAgo(story.updated_at)}</span>
1140
+ ${renderOutcomeChip(story.outcome)}
1141
+ </div>
1142
+ </div>
1143
+ `;
1144
+ }
1145
+
1146
+ /**
1147
+ * KJC-TSK-0394 step 5: result "efectivo" para la UI. Sobre el campo
1148
+ * persistido `story.result` (si existe), o inferido del status legacy
1149
+ * (done→pass, failed→fail). Permite pintar badges en plans antiguos
1150
+ * sin requerir migración previa con `kj plan migrate-result`.
1151
+ *
1152
+ * Espejo de plan-schema.js::effectiveResult — necesario aquí porque la
1153
+ * UI corre en el browser y no puede importar módulos Node del backend.
1154
+ */
1155
+ function computeEffectiveResult(story) {
1156
+ if (!story) return null;
1157
+ if (story.result !== undefined && story.result !== null) return story.result;
1158
+ if (story.status === 'done') return 'pass';
1159
+ if (story.status === 'failed') return 'fail';
1160
+ return null;
1161
+ }
1162
+
1163
+ /**
1164
+ * KJC-TSK-0394 step 2: render the per-HU `result` badge. Ortogonal al
1165
+ * status — el status es lifecycle (pending → running → done), el result
1166
+ * es el último outcome conocido de la ejecución (pass / fail / partial).
1167
+ * Sin result = nunca se ha ejecutado, no se pinta nada.
1168
+ *
1169
+ * @param {string|null} result
1170
+ * @returns {string} HTML
1171
+ */
1172
+ function renderResultBadge(result) {
1173
+ if (!result) return '';
1174
+ const icons = { pass: '✓', fail: '✗', partial: '~' };
1175
+ const titles = {
1176
+ pass: 'Última ejecución: todos los tests pasaron',
1177
+ fail: 'Última ejecución: falló',
1178
+ partial: 'Última ejecución: pasó parcialmente',
1179
+ };
1180
+ const icon = icons[result];
1181
+ if (!icon) return '';
1182
+ return `<span class="story-card__result result--${esc(result)}" title="${esc(titles[result])}">${icon}</span>`;
1183
+ }
1184
+
1185
+ /**
1186
+ * PR3: render the per-HU outcome chip (📄 ver resumen) when an
1187
+ * outcome is stamped. Click → opens the outcome modal with the
1188
+ * full breakdown (iterations, duration, commits, blockers, summary).
1189
+ *
1190
+ * The outcome arrives as a JSON-stringified blob from the SQLite
1191
+ * `outcome` column or as a raw object when the API returns the
1192
+ * parsed plan. Tolerate both shapes.
1193
+ *
1194
+ * @param {string|object|null} outcome
1195
+ * @returns {string} HTML
1196
+ */
1197
+ function renderOutcomeChip(outcome) {
1198
+ if (!outcome) return '';
1199
+ let parsed;
1200
+ try { parsed = typeof outcome === 'string' ? JSON.parse(outcome) : outcome; }
1201
+ catch { return ''; }
1202
+ if (!parsed || typeof parsed !== 'object') return '';
1203
+ const summary = parsed.summary || `Resultado: ${parsed.status || 'desconocido'}`;
1204
+ return `
1205
+ <span class="story-card__outcome-chip"
1206
+ 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);"
1207
+ title="${esc(summary)}"
1208
+ onclick="event.stopPropagation(); showOutcomeModal('${esc(JSON.stringify(parsed).replace(/'/g, '&#39;'))}');">
1209
+ 📄 resumen
1210
+ </span>
1211
+ `;
1212
+ }
1213
+
1214
+ /**
1215
+ * Renders the sessions view.
1216
+ */
1217
+ async function renderSessions() {
1218
+ const app = document.getElementById('app');
1219
+ app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading sessions...</p></div>';
1220
+
1221
+ try {
1222
+ let sessions;
1223
+ if (selectedProject) {
1224
+ sessions = await api(`/api/projects/${encodeURIComponent(selectedProject)}/sessions`);
1225
+ } else {
1226
+ sessions = await api('/api/sessions');
1227
+ }
1228
+
1229
+ if (sessions.length === 0) {
1230
+ app.innerHTML = `
1231
+ <div class="section-header">
1232
+ <span class="section-header__title">Sessions</span>
1233
+ <span class="section-header__count">0 sessions</span>
1234
+ </div>
1235
+ ${renderEmptyState('No sessions found', 'KJ sessions will appear here when you run karajan.')}
1236
+ `;
1237
+ return;
1238
+ }
1239
+
1240
+ app.innerHTML = `
1241
+ <div class="section-header">
1242
+ <span class="section-header__title">Sessions${selectedProject ? ` - ${esc(selectedProject)}` : ''}</span>
1243
+ <span class="section-header__count">${sessions.length} sessions</span>
1244
+ </div>
1245
+ <div class="sessions-list">
1246
+ ${sessions.map(renderSessionCard).join('')}
1247
+ </div>
1248
+ `;
1249
+ } catch (err) {
1250
+ app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading sessions</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * Renders a session card.
1256
+ * @param {object} session
1257
+ * @returns {string}
1258
+ */
1259
+ function renderSessionCard(session) {
1260
+ const stages = session.stages_completed ? JSON.parse(session.stages_completed) : [];
1261
+ const label = formatSessionLabel(session);
1262
+
1263
+ return `
1264
+ <div class="session-card" onclick="showSessionDetail('${esc(session.id)}')">
1265
+ <div class="session-card__header">
1266
+ <span class="session-card__title" title="${esc(session.id)}">${esc(label.title)}</span>
1267
+ <span class="session-card__status session-status--${session.status || 'unknown'}">${esc(session.status || 'unknown')}</span>
1268
+ </div>
1269
+ ${label.subtitle ? `<div class="session-card__task">${esc(label.subtitle)}</div>` : ''}
1270
+ <div class="session-card__meta">
1271
+ <span>Iterations: ${session.iterations || 0}</span>
1272
+ <span>Duration: ${formatDuration(session.duration_ms)}</span>
1273
+ <span>Stages: ${stages.join(', ') || '--'}</span>
1274
+ <span>${timeAgo(session.created_at)}</span>
1275
+ </div>
1276
+ <div class="session-card__id-chip" title="Session ID — use with kj resume &lt;id&gt;">${esc(label.idChip)}</div>
1277
+ </div>
1278
+ `;
1279
+ }
1280
+
1281
+ /**
1282
+ * Renders an empty state component.
1283
+ * @param {string} title
1284
+ * @param {string} text
1285
+ * @returns {string}
1286
+ */
1287
+ function renderEmptyState(title, text) {
1288
+ return `
1289
+ <div class="empty-state">
1290
+ <div class="empty-state__title">${title || 'No data yet'}</div>
1291
+ <div class="empty-state__text">${text || 'HU stories and sessions will appear here as Karajan processes them.'}</div>
1292
+ <div class="empty-state__path">~/.karajan/hu-stories/</div>
1293
+ </div>
1294
+ `;
1295
+ }
1296
+
1297
+ // ---- Plan execution ----
1298
+ //
1299
+ // The board delegates execution to the CLI via `POST /api/projects/:id/run`.
1300
+ // There's no more intermediate "certify" step: the user reviews HUs
1301
+ // (editing inline if needed — see edit-in-place PR), and when happy
1302
+ // clicks "Run plan", which spawns `kj run --plan <id>` as a detached
1303
+ // child on the server. Status updates flow back through the plan JSON
1304
+ // watcher without any manual refresh.
1305
+ //
1306
+ // We still expose a per-HU status PATCH endpoint for corrective moves
1307
+ // (e.g. retry a failed HU manually), but it's no longer the primary
1308
+ // action on the card.
1309
+
1310
+ /**
1311
+ * Launch the pipeline over every plan of a project. Returns when the
1312
+ * spawn ack is received — not when the run completes. Live progress is
1313
+ * reflected automatically by the plans-dir watcher on the server, so
1314
+ * the board re-renders without us having to poll.
1315
+ * @param {string} projectId
1316
+ */
1317
+ // Track the most recently launched planId so the "View log" button in
1318
+ // the section header can re-open the viewer without the user hunting
1319
+ // through plan ids.
1320
+ let lastLaunchedPlanId = null;
1321
+
1322
+ // Generalised across plan runs AND ⚡ launcher commands: the ▶ Run
1323
+ // plan button populates this with the run's logPath, and the
1324
+ // command launcher does the same with its own commandId. The 📜
1325
+ // View log button in the section header reads from here so it
1326
+ // re-opens whichever was most recently launched.
1327
+ let lastOpenedLog = null; // { id, label, tailUrl(offset) }
1328
+
1329
+ // ---- Preflight panel + safety modal ----
1330
+ //
1331
+ // Goal: when a non-technical user opens a project, they should see at
1332
+ // a glance whether the environment is ready (git initialised, remote
1333
+ // configured, agents installed, Node version, etc.). Each missing
1334
+ // item is rendered with a plain-Spanish "consequence" so the user
1335
+ // understands what's at stake.
1336
+ //
1337
+ // The panel doubles as the gate for ▶ Run plan / ▶ Run HU: if any
1338
+ // blocker (status:fail with blocking:true) is present, we refuse to
1339
+ // launch. If only warnings are present, we open a confirm modal
1340
+ // listing them and require an explicit "Lanzar de todos modos".
1341
+
1342
+ let preflightCache = null; // memoise per-render so the modal can re-use what the panel already fetched
1343
+
1344
+ async function fetchPreflight(projectId) {
1345
+ try {
1346
+ const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/preflight`);
1347
+ if (!r.ok) return null;
1348
+ return await r.json();
1349
+ } catch { return null; }
1350
+ }
1351
+
1352
+ function preflightStatusIcon(status) {
1353
+ if (status === 'ok') return '✓';
1354
+ if (status === 'warn') return '⚠';
1355
+ if (status === 'fail') return '✗';
1356
+ return 'ℹ';
1357
+ }
1358
+ function preflightStatusColor(status) {
1359
+ if (status === 'ok') return 'var(--color-green)';
1360
+ if (status === 'warn') return 'var(--color-yellow,#eab308)';
1361
+ if (status === 'fail') return 'var(--color-red,#ef4444)';
1362
+ return 'var(--text-muted)';
1363
+ }
1364
+
1365
+ async function renderPreflightPanel(projectId) {
1366
+ const panel = document.getElementById('preflight-panel');
1367
+ if (!panel) return;
1368
+ const data = await fetchPreflight(projectId);
1369
+ preflightCache = data;
1370
+ if (!data) {
1371
+ panel.innerHTML = `<div style="font-size:0.8rem;color:var(--text-muted);padding:6px 10px;">Estado del proyecto: no disponible.</div>`;
1372
+ return;
1373
+ }
1374
+ const blockerCount = data.blockers?.length || 0;
1375
+ const warningCount = data.warnings?.length || 0;
1376
+ const okCount = (data.checks || []).filter(c => c.status === 'ok').length;
1377
+ let summaryColor, summaryIcon, summaryText;
1378
+ if (blockerCount > 0) {
1379
+ summaryColor = 'var(--color-red,#ef4444)';
1380
+ summaryIcon = '✗';
1381
+ summaryText = `${blockerCount} problema(s) bloqueante(s) — la ejecución no funcionará correctamente`;
1382
+ } else if (warningCount > 0) {
1383
+ summaryColor = 'var(--color-yellow,#eab308)';
1384
+ summaryIcon = '⚠';
1385
+ summaryText = `${warningCount} aviso(s) — la ejecución funcionará pero con limitaciones`;
1386
+ } else {
1387
+ summaryColor = 'var(--color-green)';
1388
+ summaryIcon = '✓';
1389
+ summaryText = `Todo listo · ${okCount} comprobaciones OK`;
1390
+ }
1391
+
1392
+ // Compact header (always visible) + collapsed details (toggleable).
1393
+ panel.innerHTML = `
1394
+ <div class="preflight-panel__header"
1395
+ style="display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--bg-primary);border:1px solid var(--border);border-left:3px solid ${summaryColor};border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem;"
1396
+ data-toggle>
1397
+ <span style="color:${summaryColor};font-weight:700;font-size:1rem;">${summaryIcon}</span>
1398
+ <span style="color:var(--text);">${esc(summaryText)}</span>
1399
+ <span style="margin-left:auto;color:var(--text-muted);font-size:0.78rem;" data-arrow>▼ ver detalles</span>
1400
+ </div>
1401
+ <div class="preflight-panel__details" style="display:none;margin-top:6px;padding:10px 12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);">
1402
+ <div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:8px;">
1403
+ ${(data.checks || []).map((c) => `
1404
+ <div style="display:flex;align-items:flex-start;gap:8px;padding:6px 8px;background:var(--bg-secondary,var(--bg));border-radius:var(--radius-sm);"
1405
+ title="${esc(c.consequence || '')}">
1406
+ <span style="color:${preflightStatusColor(c.status)};font-weight:700;font-size:0.95rem;flex-shrink:0;">${preflightStatusIcon(c.status)}</span>
1407
+ <div style="flex:1;min-width:0;">
1408
+ <div style="font-size:0.8rem;font-weight:600;color:var(--text);">${esc(c.label)}</div>
1409
+ <div style="font-size:0.72rem;color:var(--text-muted);word-break:break-all;">${esc(c.detail || '')}</div>
1410
+ ${c.consequence ? `<div style="font-size:0.7rem;color:${preflightStatusColor(c.status)};margin-top:2px;">${esc(c.consequence)}</div>` : ''}
1411
+ </div>
1412
+ </div>
1413
+ `).join('')}
1414
+ </div>
1415
+ </div>
1416
+ `;
1417
+ // Toggle expand/collapse without rerender.
1418
+ const header = panel.querySelector('[data-toggle]');
1419
+ const details = panel.querySelector('.preflight-panel__details');
1420
+ const arrow = panel.querySelector('[data-arrow]');
1421
+ if (header && details && arrow) {
1422
+ header.addEventListener('click', () => {
1423
+ const open = details.style.display !== 'none';
1424
+ details.style.display = open ? 'none' : 'block';
1425
+ arrow.textContent = open ? '▼ ver detalles' : '▲ ocultar detalles';
1426
+ });
1427
+ }
1428
+ }
1429
+
1430
+ /**
1431
+ * Pre-run safety modal. Returns true if the user wants to proceed,
1432
+ * false to cancel. Call BEFORE invoking the run endpoint.
1433
+ *
1434
+ * Behaviour:
1435
+ * - All green → returns true immediately (no modal).
1436
+ * - Blockers present → modal explains them in plain Spanish; only
1437
+ * "Cancelar" button (no proceed-anyway) because blockers are
1438
+ * known to break the run.
1439
+ * - Only warnings → modal lists them with their consequences and
1440
+ * offers "Cancelar" or "Lanzar de todos modos" (red button so
1441
+ * the user notices they're accepting risk).
1442
+ */
1443
+ async function confirmRunWithPreflight(projectId) {
1444
+ const data = preflightCache || await fetchPreflight(projectId);
1445
+ if (!data) return true; // can't fetch → don't block the user
1446
+ const blockers = data.blockers || [];
1447
+ const warnings = data.warnings || [];
1448
+ if (blockers.length === 0 && warnings.length === 0) return true;
1449
+
1450
+ return await new Promise((resolve) => {
1451
+ const dlg = document.getElementById('app-dialog') || ensureDialog();
1452
+ const isBlocked = blockers.length > 0;
1453
+ const headerColor = isBlocked ? 'var(--color-red,#ef4444)' : 'var(--color-yellow,#eab308)';
1454
+ const allItems = [...blockers, ...warnings];
1455
+ dlg.innerHTML = `
1456
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;background:${headerColor};color:#000;">
1457
+ <span style="font-size:1.2rem;">${isBlocked ? '✗' : '⚠'}</span>
1458
+ <span>${isBlocked ? 'No se puede lanzar — falta algo crítico' : 'Atención antes de lanzar'}</span>
1459
+ </div>
1460
+ <div style="padding:14px 18px;max-height:60vh;overflow:auto;">
1461
+ <p style="margin:0 0 10px;font-size:0.9rem;color:var(--text);">
1462
+ ${isBlocked
1463
+ ? 'Se detectaron problemas bloqueantes que impedirán que Karajan funcione correctamente:'
1464
+ : 'Se detectaron avisos. Karajan puede ejecutarse, pero con limitaciones:'}
1465
+ </p>
1466
+ <ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px;">
1467
+ ${allItems.map((c) => `
1468
+ <li style="padding:8px 10px;background:var(--bg-primary);border-left:3px solid ${preflightStatusColor(c.status)};border-radius:var(--radius-sm);">
1469
+ <div style="font-weight:600;color:var(--text);font-size:0.85rem;">
1470
+ ${preflightStatusIcon(c.status)} ${esc(c.label)} — <span style="color:var(--text-muted);font-weight:400;">${esc(c.detail || '')}</span>
1471
+ </div>
1472
+ ${c.consequence ? `<div style="font-size:0.8rem;color:var(--text);margin-top:4px;">${esc(c.consequence)}</div>` : ''}
1473
+ </li>
1474
+ `).join('')}
1475
+ </ul>
1476
+ </div>
1477
+ <div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
1478
+ <button id="preflight-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
1479
+ ${isBlocked ? '' : `<button id="preflight-proceed" style="padding:8px 16px;background:var(--color-red,#ef4444);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Lanzar de todos modos</button>`}
1480
+ </div>
1481
+ `;
1482
+ if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
1483
+ const cancel = dlg.querySelector('#preflight-cancel');
1484
+ const proceed = dlg.querySelector('#preflight-proceed');
1485
+ const close = (val) => { try { dlg.close(); } catch { /* ignore */ } resolve(val); };
1486
+ cancel?.addEventListener('click', () => close(false), { once: true });
1487
+ proceed?.addEventListener('click', () => close(true), { once: true });
1488
+ dlg.addEventListener('close', () => resolve(false), { once: true });
1489
+ });
1490
+ }
1491
+ /**
1492
+ * PR4: launch a single HU from the per-card ▶ button. Confirms with
1493
+ * the user (so an accidental click doesn't kick a run) and POSTs to
1494
+ * the new /api/hus/:huId/run endpoint, which resolves the planId
1495
+ * server-side from the SQLite row.
1496
+ *
1497
+ * Exposed on `window` because the inline onclick on the card calls it.
1498
+ */
1499
+ window.runSingleHuFromCard = async function runSingleHuFromCard(huId, title) {
1500
+ const friendlyTitle = (title || huId).replace(/&#39;/g, "'");
1501
+ const ok = await showConfirm(
1502
+ `¿Lanzar solo esta HU?\n\n${friendlyTitle}\n\nKarajan ejecutará únicamente esta HU; las demás del plan no se tocarán.`,
1503
+ { title: 'Lanzar HU individual', okLabel: 'Lanzar', cancelLabel: 'Cancelar' }
1504
+ );
1505
+ if (!ok) return;
1506
+ try {
1507
+ const res = await fetch(`/api/hus/${encodeURIComponent(huId)}/run`, {
1508
+ method: 'POST',
1509
+ headers: { 'Content-Type': 'application/json' },
1510
+ body: JSON.stringify({}),
1511
+ });
1512
+ if (!res.ok) {
1513
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
1514
+ await showError(msg, { title: 'No se pudo lanzar la HU' });
1515
+ return;
1516
+ }
1517
+ const body = await res.json();
1518
+ // Surface the log path so the user can tail it from the View log button
1519
+ // AND auto-open the viewer so the click produces visible feedback. Without
1520
+ // the open, the user clicks Lanzar, sees nothing change, and concludes the
1521
+ // run never started — the run IS started, the log IS being written, the
1522
+ // UI just wasn't showing it.
1523
+ //
1524
+ // The tail URL must be derived from body.logPath, not constructed from
1525
+ // huId. When `kj run --plan ... --hu <id>` is launched, runPlan writes to
1526
+ // <runsDir>/<planId>--hu-<localHuId>.log
1527
+ // — not <runsDir>/hu-<huId>.log. The previous code guessed the wrong
1528
+ // filename and the viewer would have hit a non-existent file even if
1529
+ // it had been opened.
1530
+ if (body.logPath) {
1531
+ const logBasename = body.logPath.split('/').pop().replace(/\.log$/, '');
1532
+ const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
1533
+ lastOpenedLog = { id: `hu-${huId}`, label: `HU ${huId}`, tailUrl };
1534
+ openGenericLogPanel({ id: logBasename, label: `HU ${huId}`, tailUrl });
1535
+ }
1536
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
1537
+ await renderBoard();
1538
+ } catch (err) {
1539
+ await showError(err.message || String(err), { title: 'Fallo al lanzar la HU' });
1540
+ }
1541
+ };
1542
+
1543
+ /**
1544
+ * KJC-TSK-0394 step 2: devolver una HU al estado `pending` desde
1545
+ * cualquier estado no-terminal (coding/reviewing/blocked zombi) o
1546
+ * terminal (done/failed) que el usuario quiera relanzar limpio.
1547
+ *
1548
+ * No tocamos el campo `result`: si una HU acaba en done+fail y el
1549
+ * usuario hace Reset → status: pending, result: fail (badge ✗ persiste
1550
+ * como historial). El siguiente run sobreescribirá result cuando
1551
+ * termine.
1552
+ *
1553
+ * El backend ya acepta {status: 'pending'} en ALLOWED_STORY_STATUSES
1554
+ * (ver routes/api.js); plan-mutations::setHuStatus refleja el cambio
1555
+ * al fichero del plan y resincroniza la BBDD.
1556
+ */
1557
+ /**
1558
+ * Cambio libre de status desde el dropdown del modal. PATCH al backend
1559
+ * con el status nuevo + confirmación (algunos targets como `done` /
1560
+ * `failed` son blast-radius alto: no se puede deshacer sin Reset).
1561
+ *
1562
+ * El propio backend valida que `newStatus` esté en ALLOWED_STORY_
1563
+ * STATUSES, así que si el dropdown trae basura el servidor rechaza con
1564
+ * 400 — no necesitamos re-validar aquí.
1565
+ */
1566
+ window.changeHuStatusFromModal = async function changeHuStatusFromModal(storyId, newStatus, oldStatus) {
1567
+ // Opción "Cambiar a…" placeholder: el usuario abrió el dropdown sin
1568
+ // elegir nada. Selecciona el placeholder otra vez para no quedar en
1569
+ // un estado ambiguo.
1570
+ if (!newStatus) return;
1571
+ const select = document.getElementById('hu-status-select');
1572
+ const ok = await showConfirm(
1573
+ `Cambiar el status de esta HU de "${oldStatus}" a "${newStatus}"?\n\nEl plan file y el board se actualizan al instante. El campo result (badge ✓/✗) se conserva como historial.`,
1574
+ { title: 'Cambiar status', okLabel: 'Cambiar', cancelLabel: 'Cancelar' }
1575
+ );
1576
+ if (!ok) { if (select) select.value = ''; return; }
1577
+ try {
1578
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
1579
+ method: 'PATCH',
1580
+ headers: { 'Content-Type': 'application/json' },
1581
+ body: JSON.stringify({ status: newStatus }),
1582
+ });
1583
+ if (!res.ok) {
1584
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
1585
+ await showError(msg, { title: 'No se pudo cambiar el status' });
1586
+ if (select) select.value = '';
1587
+ return;
1588
+ }
1589
+ closeModal();
1590
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
1591
+ await renderBoard();
1592
+ } catch (err) {
1593
+ await showError(err.message || String(err), { title: 'Fallo al cambiar el status' });
1594
+ if (select) select.value = '';
1595
+ }
1596
+ };
1597
+
1598
+ // KJC-TSK-0406: persist coder_model + reviewer_model overrides from the
1599
+ // modal. Vacío → null (re-asignación automática por triage en siguiente
1600
+ // run). Cada modelo es independiente.
1601
+ window.saveHuModels = async function saveHuModels(storyId) {
1602
+ const coderInput = document.getElementById('hu-coder-model');
1603
+ const reviewerInput = document.getElementById('hu-reviewer-model');
1604
+ const patch = {
1605
+ coder_model: coderInput?.value?.trim() || null,
1606
+ reviewer_model: reviewerInput?.value?.trim() || null,
1607
+ };
1608
+ try {
1609
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
1610
+ method: 'PATCH',
1611
+ headers: { 'Content-Type': 'application/json' },
1612
+ body: JSON.stringify(patch),
1613
+ });
1614
+ if (!res.ok) {
1615
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
1616
+ await showError(msg, { title: 'No se pudieron guardar los modelos' });
1617
+ return;
1618
+ }
1619
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
1620
+ await renderBoard();
1621
+ } catch (err) {
1622
+ await showError(err.message || String(err), { title: 'Fallo guardando modelos' });
1623
+ }
1624
+ };
1625
+
1626
+ // KJC-TSK-0408: deshace los cambios de una HU restaurando el snapshot
1627
+ // git tomado pre-run. ATENCIÓN: es destructivo (git reset --hard) —
1628
+ // confirmamos siempre antes de invocar.
1629
+ window.undoHuChanges = async function undoHuChanges(storyId) {
1630
+ const ok = await showConfirm(
1631
+ '⚠️ Esta acción es destructiva.\n\nSe descartan los cambios de ficheros que esta HU hizo durante su última ejecución y se restaura el estado pre-run. La HU vuelve a pending y podrás relanzarla con otros modelos.\n\n¿Continuar?',
1632
+ { title: 'Deshacer cambios de la HU', okLabel: 'Deshacer', cancelLabel: 'Cancelar' }
1633
+ );
1634
+ if (!ok) return;
1635
+ try {
1636
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}/undo`, { method: 'POST' });
1637
+ if (!res.ok) {
1638
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
1639
+ await showError(msg, { title: 'No se pudo deshacer la HU' });
1640
+ return;
1641
+ }
1642
+ closeModal();
1643
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
1644
+ await renderBoard();
1645
+ } catch (err) {
1646
+ await showError(err.message || String(err), { title: 'Fallo al deshacer la HU' });
1647
+ }
1648
+ };
1649
+
1650
+ window.resetHuToPending = async function resetHuToPending(storyId) {
1651
+ const ok = await showConfirm(
1652
+ '¿Devolver esta HU a pending?\n\nEl estado se cambia a pending y se podrá relanzar. El resultado de la última ejecución (✓/✗/~) se conserva como historial hasta que vuelvas a ejecutarla.',
1653
+ { title: 'Reset HU', okLabel: 'Reset', cancelLabel: 'Cancelar' }
1654
+ );
1655
+ if (!ok) return;
1656
+ try {
1657
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
1658
+ method: 'PATCH',
1659
+ headers: { 'Content-Type': 'application/json' },
1660
+ body: JSON.stringify({ status: 'pending' }),
1661
+ });
1662
+ if (!res.ok) {
1663
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
1664
+ await showError(msg, { title: 'No se pudo resetear la HU' });
1665
+ return;
1666
+ }
1667
+ closeModal();
1668
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
1669
+ await renderBoard();
1670
+ } catch (err) {
1671
+ await showError(err.message || String(err), { title: 'Fallo al resetear la HU' });
1672
+ }
1673
+ };
1674
+
1675
+ // ---- PR3: outcome modal + plan rollup banner ----
1676
+ //
1677
+ // Surfaces the per-HU + plan-level execution outcome the orchestrator
1678
+ // stamps on the plan JSON. Plain Spanish, no jargon, designed for a
1679
+ // non-technical user reviewing what happened during the run.
1680
+
1681
+ /**
1682
+ * Open the outcome detail modal for a single HU. The chip on each
1683
+ * card calls this with the JSON-stringified outcome (escaped for the
1684
+ * inline onclick attribute) — we parse and render the breakdown.
1685
+ *
1686
+ * Exposed on `window` because it's invoked from the inline onclick
1687
+ * attribute renderOutcomeChip writes.
1688
+ */
1689
+ /**
1690
+ * PR-G: rename a project from the header ✎ button. Opens a small
1691
+ * dialog with the current name pre-filled, validates length, PUTs
1692
+ * to /api/projects/:id/name, and re-renders the board so the new
1693
+ * name shows up everywhere (header, dropdown, picker).
1694
+ *
1695
+ * Exposed on `window` because the inline onclick attribute on the
1696
+ * pencil button calls it.
1697
+ */
1698
+ window.renameProjectModal = function renameProjectModal(projectId, currentName) {
1699
+ const dlg = document.getElementById('app-dialog') || ensureDialog();
1700
+ const safeCurrent = String(currentName || '').replace(/&#39;/g, "'");
1701
+ dlg.innerHTML = `
1702
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
1703
+ <span style="font-size:1.1rem;">✎</span>
1704
+ <span>Renombrar proyecto</span>
1705
+ </div>
1706
+ <div style="padding:14px 18px;">
1707
+ <label for="rename-input" style="display:block;font-size:0.85rem;color:var(--text);margin-bottom:6px;">
1708
+ Nombre del proyecto
1709
+ </label>
1710
+ <input type="text" id="rename-input" value="${esc(safeCurrent)}" maxlength="120"
1711
+ style="width:100%;padding:8px 10px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);font-size:0.95rem;" />
1712
+ <p style="margin:8px 0 0;font-size:0.75rem;color:var(--text-muted);">
1713
+ Se actualizará el campo <code>name</code> en cada plan JSON de este proyecto y la fila en la base de datos del board.
1714
+ </p>
1715
+ <p id="rename-error" style="margin:8px 0 0;font-size:0.78rem;color:var(--color-red,#ef4444);display:none;"></p>
1716
+ </div>
1717
+ <div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
1718
+ <button id="rename-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
1719
+ <button id="rename-save" style="padding:8px 16px;background:var(--color-green);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Guardar</button>
1720
+ </div>
1721
+ `;
1722
+ if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
1723
+ const input = dlg.querySelector('#rename-input');
1724
+ const errorEl = dlg.querySelector('#rename-error');
1725
+ input?.focus();
1726
+ input?.select();
1727
+ dlg.querySelector('#rename-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
1728
+ dlg.querySelector('#rename-save')?.addEventListener('click', async () => {
1729
+ const newName = input?.value?.trim() || '';
1730
+ if (!newName) { errorEl.style.display = 'block'; errorEl.textContent = 'El nombre no puede estar vacío.'; return; }
1731
+ if (newName === safeCurrent) { try { dlg.close(); } catch { /* ignore */ } return; }
1732
+ try {
1733
+ const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/name`, {
1734
+ method: 'PUT',
1735
+ headers: { 'Content-Type': 'application/json' },
1736
+ body: JSON.stringify({ name: newName }),
1737
+ });
1738
+ if (!r.ok) {
1739
+ const body = await r.json().catch(() => ({}));
1740
+ errorEl.style.display = 'block';
1741
+ errorEl.textContent = body?.error || `HTTP ${r.status}`;
1742
+ return;
1743
+ }
1744
+ // Update the cached display name + refresh the board so the
1745
+ // header, dropdown and project picker all show the new value.
1746
+ projectNameCache[projectId] = newName;
1747
+ try { dlg.close(); } catch { /* ignore */ }
1748
+ await populateProjectSelect();
1749
+ await renderBoard();
1750
+ } catch (err) {
1751
+ errorEl.style.display = 'block';
1752
+ errorEl.textContent = err.message || String(err);
1753
+ }
1754
+ }, { once: true });
1755
+ };
1756
+
1757
+ window.showOutcomeModal = function showOutcomeModal(escapedJson) {
1758
+ let outcome;
1759
+ try { outcome = JSON.parse(String(escapedJson).replace(/&#39;/g, "'")); }
1760
+ catch { return; }
1761
+ const dlg = document.getElementById('app-dialog') || ensureDialog();
1762
+ const status = outcome.status || 'desconocido';
1763
+ const headerColor = status === 'done' ? 'var(--color-green)'
1764
+ : status === 'failed' ? 'var(--color-red,#ef4444)'
1765
+ : 'var(--color-yellow,#eab308)';
1766
+ const headerLabel = status === 'done' ? 'HU completada'
1767
+ : status === 'failed' ? 'HU fallida'
1768
+ : status === 'blocked' ? 'HU bloqueada'
1769
+ : `HU · ${status}`;
1770
+ const durationText = outcome.duration_ms != null ? formatDuration(outcome.duration_ms) : '—';
1771
+ const blockersHtml = (outcome.blockers || []).length === 0 ? '' : `
1772
+ <div style="margin-top:10px;">
1773
+ <div style="font-weight:600;color:var(--color-red,#ef4444);font-size:0.85rem;">Problemas detectados</div>
1774
+ <ul style="margin:4px 0 0 18px;padding:0;color:var(--text);">
1775
+ ${outcome.blockers.map(b => `<li style="font-size:0.8rem;margin-top:4px;">${esc(b)}</li>`).join('')}
1776
+ </ul>
1777
+ </div>
1778
+ `;
1779
+ const commitsHtml = (outcome.commits || []).length === 0 ? '' : `
1780
+ <div style="margin-top:10px;">
1781
+ <div style="font-weight:600;color:var(--text);font-size:0.85rem;">Commits</div>
1782
+ <ul style="margin:4px 0 0 18px;padding:0;color:var(--text-muted);">
1783
+ ${outcome.commits.map(c => `<li style="font-family:var(--font-mono,monospace);font-size:0.78rem;">${esc(c)}</li>`).join('')}
1784
+ </ul>
1785
+ </div>
1786
+ `;
1787
+ dlg.innerHTML = `
1788
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;background:${headerColor};color:#000;">
1789
+ <span style="font-size:1.2rem;">📄</span>
1790
+ <span>${esc(headerLabel)}</span>
1791
+ </div>
1792
+ <div style="padding:14px 18px;max-height:65vh;overflow:auto;">
1793
+ <p style="margin:0 0 10px;color:var(--text);">${esc(outcome.summary || '(sin resumen disponible)')}</p>
1794
+ <div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:8px;font-size:0.8rem;">
1795
+ <div><strong>Iteraciones</strong>: ${outcome.iterations ?? '—'}</div>
1796
+ <div><strong>Duración</strong>: ${durationText}</div>
1797
+ <div><strong>Rama</strong>: <span style="font-family:var(--font-mono,monospace)">${esc(outcome.branch || '—')}</span></div>
1798
+ <div><strong>Pull Request</strong>: ${outcome.pr_url ? `<a href="${esc(outcome.pr_url)}" target="_blank" rel="noopener">abrir</a>` : '—'}</div>
1799
+ <div><strong>Terminada</strong>: ${esc(outcome.finishedAt || '—')}</div>
1800
+ </div>
1801
+ ${blockersHtml}
1802
+ ${commitsHtml}
1803
+ </div>
1804
+ <div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;">
1805
+ <button id="outcome-close" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cerrar</button>
1806
+ </div>
1807
+ `;
1808
+ if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
1809
+ dlg.querySelector('#outcome-close')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
1810
+ };
1811
+
1812
+ /**
1813
+ * Fetch + render the plan-level rollup banner. Shows X done / Y
1814
+ * failed / Z blocked + total duration when at least one plan in the
1815
+ * project has finished. Hidden otherwise.
1816
+ */
1817
+ async function renderPlanRollup(projectId) {
1818
+ const slot = document.getElementById('plan-rollup-banner');
1819
+ if (!slot) return;
1820
+ let data;
1821
+ try {
1822
+ const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/plans-outcome`);
1823
+ if (!r.ok) return;
1824
+ data = await r.json();
1825
+ } catch { return; }
1826
+ const finished = (data.plans || []).filter(p => p.outcome);
1827
+ if (finished.length === 0) { slot.innerHTML = ''; return; }
1828
+ // Aggregate the most recent plan first — non-tech user usually
1829
+ // cares about "what happened just now", not historical sums.
1830
+ finished.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
1831
+ const recent = finished[0];
1832
+ const o = recent.outcome;
1833
+ const c = o.counts || {};
1834
+ const summaryColor = o.status === 'done' ? 'var(--color-green)'
1835
+ : o.status === 'failed' ? 'var(--color-red,#ef4444)'
1836
+ : 'var(--color-yellow,#eab308)';
1837
+ const headline = o.status === 'done' ? 'Plan finalizado correctamente'
1838
+ : o.status === 'failed' ? 'Plan terminado con errores'
1839
+ : 'Plan terminado parcialmente';
1840
+ const durationText = o.duration_ms != null ? formatDuration(o.duration_ms) : '—';
1841
+ slot.innerHTML = `
1842
+ <div style="margin:8px 0;padding:10px 14px;background:var(--bg-primary);border:1px solid var(--border);border-left:4px solid ${summaryColor};border-radius:var(--radius-sm);font-size:0.85rem;display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
1843
+ <span style="color:${summaryColor};font-weight:700;">${esc(headline)}</span>
1844
+ <span style="color:var(--text-muted);">${esc(recent.name || recent.planId)}</span>
1845
+ <span style="color:var(--text);">✓ ${c.done || 0} hechas</span>
1846
+ ${c.failed ? `<span style="color:var(--color-red,#ef4444)">✗ ${c.failed} fallidas</span>` : ''}
1847
+ ${c.blocked ? `<span style="color:var(--color-yellow,#eab308)">⏸ ${c.blocked} bloqueadas</span>` : ''}
1848
+ <span style="color:var(--text-muted);">⏱ ${esc(durationText)}</span>
1849
+ ${(o.prs || []).length > 0 ? `<span style="color:var(--text-muted);">${o.prs.length} PR(s):</span> ${o.prs.map(u => `<a href="${esc(u)}" target="_blank" rel="noopener" style="color:var(--color-green);">abrir</a>`).join(' · ')}` : ''}
1850
+ </div>
1851
+ `;
1852
+ }
1853
+
1854
+ async function runProject(projectId) {
1855
+ try {
1856
+ const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/run`, {
1857
+ method: 'POST',
1858
+ headers: { 'Content-Type': 'application/json' },
1859
+ body: JSON.stringify({}),
1860
+ });
1861
+ if (!res.ok) {
1862
+ if (res.status === 404) {
1863
+ await showError(
1864
+ 'The board server does not recognise the "run" endpoint.\n\n'
1865
+ + 'Most likely cause: the board process is running a pre-v2.7.5 build. '
1866
+ + 'Restart it with:\n\n'
1867
+ + ' kj board stop && kj board start',
1868
+ { title: 'Board out of date' }
1869
+ );
1870
+ return;
1871
+ }
1872
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
1873
+ await showError(msg, { title: 'Could not launch run' });
1874
+ return;
1875
+ }
1876
+ const body = await res.json();
1877
+ // Best-effort: a chokidar tick may still be in flight, so fetch
1878
+ // once explicitly to surface the "running" state without waiting
1879
+ // for the watcher debounce.
1880
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
1881
+ await renderBoard();
1882
+
1883
+ // Remember the first successful planId so the section header can
1884
+ // offer a "View log" button without the user having to re-launch.
1885
+ const firstOk = (body.results || []).find((r) => r.ok);
1886
+ if (firstOk && firstOk.planId) {
1887
+ lastLaunchedPlanId = firstOk.planId;
1888
+ lastOpenedLog = {
1889
+ id: firstOk.planId,
1890
+ label: 'Run log',
1891
+ tailUrl: (offset) => `/api/plans/${encodeURIComponent(firstOk.planId)}/log?offset=${offset}`,
1892
+ };
1893
+ // Open the log viewer straight away — the user's mental model is
1894
+ // "I clicked Run, I want to see what's happening", not "I clicked
1895
+ // Run, now where do I look".
1896
+ openLogViewer(firstOk.planId);
1897
+ } else {
1898
+ await showError(
1899
+ `Pipeline launched for ${body.launched}/${body.total} plan(s) but `
1900
+ + 'no planId came back, so there\'s no log to show.',
1901
+ { title: 'Run started' }
1902
+ );
1903
+ }
1904
+ } catch (err) {
1905
+ await showError(err.message, { title: 'Could not launch run' });
1906
+ }
1907
+ }
1908
+
1909
+ // ---- Run log viewer ----
1910
+ //
1911
+ // `kj run --plan` is spawned as a detached child; output lands in
1912
+ // ~/.karajan/hu-board-runs/<planId>.log. The browser tails it via
1913
+ // GET /api/plans/:planId/log?offset=N and appends the delta.
1914
+ //
1915
+ // The viewer is a NON-modal floating panel anchored to the bottom-
1916
+ // right of the viewport — not a <dialog>, because:
1917
+ // - We want the user to keep clicking around the board while the
1918
+ // run is in progress (modal blocks that).
1919
+ // - <dialog>.showModal() centered the panel on top of the kanban;
1920
+ // when content overflowed (long log lines + min-width), the
1921
+ // positioning broke and the panel landed in a corner.
1922
+ // Three window-style controls live in the header — minimize (collapses
1923
+ // to title bar only), maximize (fills viewport), close. Polling
1924
+ // keeps running while minimized so the kB counter stays live.
1925
+
1926
+ let logPollTimer = null;
1927
+ let logViewerState = { planId: null, panel: null, offset: 0, isMax: false, isMin: false };
1928
+
1929
+ // Map common ANSI SGR codes to inline CSS. Anything we don't know is
1930
+ // stripped silently (still removes the escape, just doesn't colour).
1931
+ // We render into a styled <pre> so newlines + spaces preserve.
1932
+ const ANSI_SGR = {
1933
+ 1: 'font-weight:bold',
1934
+ 2: 'opacity:0.65',
1935
+ 3: 'font-style:italic',
1936
+ 4: 'text-decoration:underline',
1937
+ 30: 'color:#1f2937', 31: 'color:#f87171', 32: 'color:#4ade80',
1938
+ 33: 'color:#fbbf24', 34: 'color:#60a5fa', 35: 'color:#c084fc',
1939
+ 36: 'color:#22d3ee', 37: 'color:#e5e7eb',
1940
+ 90: 'color:#9ca3af', 91: 'color:#fca5a5', 92: 'color:#86efac',
1941
+ 93: 'color:#fde68a', 94: 'color:#93c5fd', 95: 'color:#d8b4fe',
1942
+ 96: 'color:#67e8f9', 97: 'color:#f3f4f6',
1943
+ };
1944
+
1945
+ /**
1946
+ * Map an xterm 256-colour index to a CSS rgb(). 0-15 = the 16 standard
1947
+ * + bright colours; 16-231 = the 6x6x6 RGB cube; 232-255 = grayscale.
1948
+ */
1949
+ function ansi256ToCss(n) {
1950
+ if (n < 0 || n > 255) return 'inherit';
1951
+ const std = [
1952
+ '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
1953
+ '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff',
1954
+ ];
1955
+ if (n < 16) return std[n];
1956
+ if (n < 232) {
1957
+ const idx = n - 16;
1958
+ const r = Math.floor(idx / 36);
1959
+ const g = Math.floor(idx / 6) % 6;
1960
+ const b = idx % 6;
1961
+ const v = (c) => (c === 0 ? 0 : 55 + c * 40);
1962
+ return `rgb(${v(r)},${v(g)},${v(b)})`;
1963
+ }
1964
+ const v = 8 + (n - 232) * 10;
1965
+ return `rgb(${v},${v},${v})`;
1966
+ }
1967
+
1968
+ const ESC_CHARS = new Set([0x1B, 0x241B]); // real ESC + Unicode "Symbol For Escape"
1969
+ const HTML_ESC = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1970
+
1971
+ /**
1972
+ * Convert text containing ANSI SGR escapes into HTML with inline-styled
1973
+ * spans. The previous implementation had two bugs that turned the log
1974
+ * into unreadable garbage:
1975
+ *
1976
+ * 1. It searched for `[` (just the bracket) instead of `ESC + [`,
1977
+ * so the ESC byte itself was sliced into the output. The browser
1978
+ * then rendered it as the literal substitute character (escape).
1979
+ * 2. It only knew the 16-colour codes (30-37, 90-97). Karajan's
1980
+ * banner uses 24-bit RGB (`38;2;r;g;b`) and codex emits
1981
+ * 256-colour (`38;5;n`); both showed as plain unstyled text with
1982
+ * the escape sequences leaking through.
1983
+ *
1984
+ * Also accepts U+241B "Symbol For Escape" which some loggers /
1985
+ * clipboards substitute for the raw 0x1B byte.
1986
+ *
1987
+ * @param {string} text
1988
+ * @returns {string} safe HTML
1989
+ */
1990
+ function ansiToHtml(text) {
1991
+ const src = String(text);
1992
+ const N = src.length;
1993
+ let out = '';
1994
+ let openSpans = 0;
1995
+ let i = 0;
1996
+
1997
+ while (i < N) {
1998
+ const code = src.charCodeAt(i);
1999
+ // Not an escape: fast-batch the literal run up to the next escape.
2000
+ if (!ESC_CHARS.has(code)) {
2001
+ let j = i + 1;
2002
+ while (j < N && !ESC_CHARS.has(src.charCodeAt(j))) j += 1;
2003
+ out += HTML_ESC(src.slice(i, j));
2004
+ i = j;
2005
+ continue;
2006
+ }
2007
+ // Escape but not followed by `[` -- drop the lone escape byte so
2008
+ // it doesn't render as the substitute character.
2009
+ if (src[i + 1] !== '[') { i += 1; continue; }
2010
+ const endM = src.indexOf('m', i + 2);
2011
+ if (endM === -1) { i += 1; continue; }
2012
+ const params = src.slice(i + 2, endM).split(';').map((n) => Number(n) || 0);
2013
+ let k = 0;
2014
+ while (k < params.length) {
2015
+ const c = params[k];
2016
+ if (c === 0) {
2017
+ while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
2018
+ k += 1;
2019
+ } else if ((c === 38 || c === 48) && params[k + 1] === 2 && params.length >= k + 5) {
2020
+ // 24-bit truecolor: 38;2;r;g;b (foreground) or 48;2;r;g;b (background)
2021
+ const r = params[k + 2], g = params[k + 3], b = params[k + 4];
2022
+ const prop = c === 38 ? 'color' : 'background-color';
2023
+ out += `<span style="${prop}:rgb(${r},${g},${b})">`;
2024
+ openSpans += 1;
2025
+ k += 5;
2026
+ } else if ((c === 38 || c === 48) && params[k + 1] === 5 && params.length >= k + 3) {
2027
+ // 256-colour palette: 38;5;n / 48;5;n
2028
+ const prop = c === 38 ? 'color' : 'background-color';
2029
+ out += `<span style="${prop}:${ansi256ToCss(params[k + 2])}">`;
2030
+ openSpans += 1;
2031
+ k += 3;
2032
+ } else if (ANSI_SGR[c]) {
2033
+ out += `<span style="${ANSI_SGR[c]}">`;
2034
+ openSpans += 1;
2035
+ k += 1;
2036
+ } else {
2037
+ k += 1; // unknown -- silently drop
2038
+ }
2039
+ }
2040
+ i = endM + 1;
2041
+ }
2042
+ while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
2043
+ return out;
2044
+ }
2045
+
2046
+ /**
2047
+ * Open (or refocus) the run-log panel for the given plan. Thin shim
2048
+ * over `openGenericLogPanel` — the original entry point used for
2049
+ * `kj run --plan` runs that the Run-plan button kicks off.
2050
+ */
2051
+ function openLogViewer(planId) {
2052
+ return openGenericLogPanel({
2053
+ id: planId,
2054
+ label: 'Run log',
2055
+ tailUrl: (offset) => `/api/plans/${encodeURIComponent(planId)}/log?offset=${offset}`,
2056
+ });
2057
+ }
2058
+
2059
+ /**
2060
+ * Generic floating log panel anchored bottom-right. Used by:
2061
+ * - openLogViewer(planId) → /api/plans/:planId/log
2062
+ * - openCommandLogViewer(cmd, id) → /api/runs/:commandId/log
2063
+ *
2064
+ * The only thing that changes between callers is the tail URL +
2065
+ * the label; the window controls (min/max/close), polling, and ANSI
2066
+ * rendering are identical.
2067
+ *
2068
+ * @param {object} args
2069
+ * @param {string} args.id - opaque id (planId or commandId), shown in header
2070
+ * @param {string} args.label - "Run log", "kj plan", …
2071
+ * @param {(offset:number)=>string} args.tailUrl - returns the URL to GET each tick
2072
+ */
2073
+ function openGenericLogPanel({ id, label, tailUrl }) {
2074
+ // Tearing down the previous panel cleanly avoids two timers running
2075
+ // when the user opens a second log while the first is still tailing.
2076
+ if (logPollTimer) { clearTimeout(logPollTimer); logPollTimer = null; }
2077
+ if (logViewerState.panel && logViewerState.panel.parentNode) {
2078
+ logViewerState.panel.remove();
2079
+ }
2080
+
2081
+ const panel = document.createElement('section');
2082
+ panel.id = 'kj-log-panel';
2083
+ panel.dataset.state = 'normal';
2084
+ panel.style.cssText = [
2085
+ 'position:fixed',
2086
+ 'right:16px',
2087
+ 'bottom:16px',
2088
+ 'width:min(720px, calc(100vw - 32px))',
2089
+ 'height:min(420px, calc(100vh - 80px))',
2090
+ 'display:flex',
2091
+ 'flex-direction:column',
2092
+ 'border:1px solid var(--border)',
2093
+ 'border-radius:var(--radius-sm)',
2094
+ 'background:var(--bg-secondary)',
2095
+ 'box-shadow:0 12px 40px rgba(0,0,0,0.45)',
2096
+ 'z-index:9999',
2097
+ 'overflow:hidden',
2098
+ ].join(';');
2099
+
2100
+ panel.innerHTML = `
2101
+ <header style="display:flex;align-items:center;justify-content:space-between;
2102
+ padding:8px 12px;background:var(--bg-primary);
2103
+ border-bottom:1px solid var(--border);
2104
+ user-select:none;flex-shrink:0">
2105
+ <div style="display:flex;align-items:baseline;gap:10px;min-width:0">
2106
+ <strong style="color:var(--text)">${esc(label)}</strong>
2107
+ <span style="font-family:var(--font-mono, monospace);font-size:0.78rem;
2108
+ color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;
2109
+ white-space:nowrap">${esc(id)}</span>
2110
+ </div>
2111
+ <div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
2112
+ <span id="log-status" style="font-size:0.75rem;color:var(--text-muted);margin-right:8px">connecting…</span>
2113
+ <button id="log-min" type="button" title="Minimize (run keeps going)"
2114
+ style="${winBtnStyle('#facc15')}">_</button>
2115
+ <button id="log-max" type="button" title="Maximize / Restore"
2116
+ style="${winBtnStyle('#34d399')}">▢</button>
2117
+ <button id="log-close" type="button" title="Close panel — the run keeps going in the background. Re-open anytime via the “📜 View log” button on the board."
2118
+ style="${winBtnStyle('#f87171')}">✕</button>
2119
+ </div>
2120
+ </header>
2121
+ <pre id="log-body"
2122
+ style="margin:0;padding:12px 16px;flex:1 1 auto;overflow:auto;
2123
+ font-family:var(--font-mono, monospace);font-size:0.8rem;
2124
+ line-height:1.45;background:#0b0b0c;color:#e6e6e6;
2125
+ white-space:pre-wrap;word-break:break-word"></pre>
2126
+ <footer style="padding:6px 12px;border-top:1px solid var(--border);
2127
+ font-size:0.7rem;color:var(--text-muted);flex-shrink:0">
2128
+ Closing this panel does NOT stop the run. It keeps going in the background;
2129
+ reopen with the 📜 View log button on the board.
2130
+ </footer>
2131
+ `;
2132
+
2133
+ document.body.appendChild(panel);
2134
+ logViewerState = { planId: id, panel, offset: 0, isMax: false, isMin: false };
2135
+
2136
+ const bodyEl = panel.querySelector('#log-body');
2137
+ const statusEl = panel.querySelector('#log-status');
2138
+ const footerEl = panel.querySelector('footer');
2139
+ const minBtn = panel.querySelector('#log-min');
2140
+ const maxBtn = panel.querySelector('#log-max');
2141
+ const closeBtn = panel.querySelector('#log-close');
2142
+
2143
+ // ---- Window controls ----
2144
+ closeBtn.addEventListener('click', () => {
2145
+ if (logPollTimer) { clearTimeout(logPollTimer); logPollTimer = null; }
2146
+ panel.remove();
2147
+ logViewerState = { planId: null, panel: null, offset: 0, isMax: false, isMin: false };
2148
+ });
2149
+
2150
+ minBtn.addEventListener('click', () => {
2151
+ logViewerState.isMin = !logViewerState.isMin;
2152
+ if (logViewerState.isMin) {
2153
+ // Collapse to header-only pill anchored at bottom-right. Polling
2154
+ // continues so the kB counter stays live; click min again to expand.
2155
+ panel.style.height = 'auto';
2156
+ panel.style.width = 'auto';
2157
+ bodyEl.style.display = 'none';
2158
+ footerEl.style.display = 'none';
2159
+ panel.dataset.state = 'min';
2160
+ } else {
2161
+ panel.style.height = logViewerState.isMax ? 'calc(100vh - 32px)' : 'min(420px, calc(100vh - 80px))';
2162
+ panel.style.width = logViewerState.isMax ? 'calc(100vw - 32px)' : 'min(720px, calc(100vw - 32px))';
2163
+ bodyEl.style.display = '';
2164
+ footerEl.style.display = '';
2165
+ panel.dataset.state = logViewerState.isMax ? 'max' : 'normal';
2166
+ }
2167
+ });
2168
+
2169
+ maxBtn.addEventListener('click', () => {
2170
+ logViewerState.isMin = false;
2171
+ logViewerState.isMax = !logViewerState.isMax;
2172
+ bodyEl.style.display = '';
2173
+ footerEl.style.display = '';
2174
+ if (logViewerState.isMax) {
2175
+ panel.style.top = '16px';
2176
+ panel.style.left = '16px';
2177
+ panel.style.right = '16px';
2178
+ panel.style.bottom = '16px';
2179
+ panel.style.width = 'auto';
2180
+ panel.style.height = 'auto';
2181
+ panel.dataset.state = 'max';
2182
+ } else {
2183
+ panel.style.top = '';
2184
+ panel.style.left = '';
2185
+ panel.style.right = '16px';
2186
+ panel.style.bottom = '16px';
2187
+ panel.style.width = 'min(720px, calc(100vw - 32px))';
2188
+ panel.style.height = 'min(420px, calc(100vh - 80px))';
2189
+ panel.dataset.state = 'normal';
2190
+ }
2191
+ });
2192
+
2193
+ // ---- Tail loop ----
2194
+ async function tick() {
2195
+ if (!logViewerState.panel) return; // user closed
2196
+ try {
2197
+ const res = await fetch(tailUrl(logViewerState.offset));
2198
+ if (!res.ok) {
2199
+ statusEl.textContent = `error: HTTP ${res.status}`;
2200
+ logPollTimer = setTimeout(tick, 4000);
2201
+ return;
2202
+ }
2203
+ const body = await res.json();
2204
+ if (!body.exists) {
2205
+ statusEl.textContent = 'waiting for run to start…';
2206
+ } else {
2207
+ if (body.content) {
2208
+ // Append rendered HTML so ANSI escape codes show as colour
2209
+ // instead of literal `[1m…[0m` noise (the user's complaint).
2210
+ // Auto-scroll only when the user was already at the bottom,
2211
+ // so manual scrollback doesn't fight the tail.
2212
+ const atBottom = bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 8;
2213
+ bodyEl.insertAdjacentHTML('beforeend', ansiToHtml(body.content));
2214
+ if (atBottom) bodyEl.scrollTop = bodyEl.scrollHeight;
2215
+ }
2216
+ logViewerState.offset = body.size;
2217
+ statusEl.textContent = `${(body.size / 1024).toFixed(1)} KB — live`;
2218
+ }
2219
+ } catch (err) {
2220
+ statusEl.textContent = `error: ${err.message}`;
2221
+ }
2222
+ logPollTimer = setTimeout(tick, 2000);
2223
+ }
2224
+
2225
+ tick();
2226
+ }
2227
+
2228
+ /** Inline style for the three window-control buttons in the log panel. */
2229
+ function winBtnStyle(accentColor) {
2230
+ return [
2231
+ 'width:22px',
2232
+ 'height:22px',
2233
+ 'padding:0',
2234
+ 'border:1px solid var(--border)',
2235
+ `background:${accentColor}`,
2236
+ 'color:#0b0b0c',
2237
+ 'font-weight:700',
2238
+ 'font-size:0.78rem',
2239
+ 'line-height:20px',
2240
+ 'border-radius:50%',
2241
+ 'cursor:pointer',
2242
+ 'display:inline-flex',
2243
+ 'align-items:center',
2244
+ 'justify-content:center',
2245
+ ].join(';');
2246
+ }
2247
+
2248
+ // ---- Detail Modals ----
2249
+
2250
+ /**
2251
+ * Shows the story detail modal.
2252
+ * @param {string} storyId
2253
+ */
2254
+ async function showStoryDetail(storyId) {
2255
+ const backdrop = document.getElementById('modal-backdrop');
2256
+ const content = document.getElementById('modal-content');
2257
+ backdrop.classList.remove('hidden');
2258
+
2259
+ content.innerHTML = '<div class="loading"><div class="loading__spinner"></div></div>';
2260
+
2261
+ try {
2262
+ const story = await api(`/api/stories/${encodeURIComponent(storyId)}`);
2263
+ const antipatterns = story.antipatterns ? JSON.parse(story.antipatterns) : [];
2264
+ const ac = story.acceptance_criteria ? JSON.parse(story.acceptance_criteria) : [];
2265
+ const tests = story.acceptance_tests ? JSON.parse(story.acceptance_tests) : [];
2266
+ const ctxRequests = story.context_requests || [];
2267
+ const initials = await resolveProjectInitials(story.project_id);
2268
+ const shortId = shortStoryId(story, initials);
2269
+
2270
+ const dimLabels = ['Independent', 'Negotiable', 'Valuable', 'Estimable', 'Small', 'Testable'];
2271
+
2272
+ // Edit-in-place is gated on plan-backed stories: legacy rows
2273
+ // (plan_id null) have no source-of-truth file to write to, so PATCH
2274
+ // would 409 anyway.
2275
+ const canEdit = ['pending', 'certified', 'needs_context', 'blocked'].includes(story.status);
2276
+ // KJC-TSK-0394 step 2: "Reset to pending" para destrabar HUs zombi
2277
+ // (coding/reviewing/blocked colgados) o relanzar limpio una HU
2278
+ // done/failed. Solo plan-backed. Pending/certified ya están en el
2279
+ // estado destino, no tiene sentido el botón.
2280
+ const canResetToPending = story.plan_id
2281
+ && !['pending', 'certified'].includes(story.status);
2282
+ // Dropdown libre de status. Solo plan-backed. La lista es la misma
2283
+ // que ALLOWED_STORY_STATUSES en el backend — NO incluye coding/
2284
+ // reviewing/running (esos los pone el orquestador; setearlos a
2285
+ // mano genera zombies en el reaper).
2286
+ const canChangeStatus = !!story.plan_id;
2287
+ // KJC-TSK-0403: 'failed' eliminado del dropdown — result=fail vive en
2288
+ // la HU via outcome.blockers, no como status manual.
2289
+ const userSettableStatuses = ['pending', 'certified', 'done', 'blocked', 'needs_context'];
2290
+
2291
+ content.innerHTML = `
2292
+ <div class="modal__header">
2293
+ <div>
2294
+ <div class="modal__title" title="${esc(story.id)}">${esc(shortId)}</div>
2295
+ <div class="modal__subtitle" style="font-size:0.75rem;color:var(--text-muted);font-family:monospace;margin-top:2px">${esc(story.id)}</div>
2296
+ <div style="display:flex;align-items:center;gap:8px;margin-top:6px">
2297
+ <span class="story-card__status status--${story.status}">${esc(story.status)}</span>
2298
+ ${canChangeStatus ? `
2299
+ <select id="hu-status-select"
2300
+ title="Cambiar manualmente el status de esta HU"
2301
+ style="padding:3px 6px;font-size:0.75rem;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer"
2302
+ onchange="changeHuStatusFromModal('${esc(story.id)}', this.value, '${esc(story.status)}')">
2303
+ <option value="">Cambiar a…</option>
2304
+ ${userSettableStatuses
2305
+ .filter((s) => s !== story.status)
2306
+ .map((s) => `<option value="${s}">${s}</option>`)
2307
+ .join('')}
2308
+ </select>
2309
+ ` : ''}
2310
+ </div>
2311
+ </div>
2312
+ <div style="display:flex;align-items:center;gap:8px">
2313
+ ${canEdit ? `
2314
+ <button id="edit-hu-btn" class="control-btn"
2315
+ style="padding:6px 12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem"
2316
+ title="Edit title, scope, task type, and acceptance criteria">
2317
+ ✎ Edit
2318
+ </button>
2319
+ ` : ''}
2320
+ ${canResetToPending ? `
2321
+ <button id="reset-hu-btn" class="control-btn"
2322
+ style="padding:6px 12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem"
2323
+ title="Devolver esta HU a pending (sin tocar el result anterior)"
2324
+ onclick="resetHuToPending('${esc(story.id)}')">
2325
+ ↺ Reset
2326
+ </button>
2327
+ ` : ''}
2328
+ ${(() => {
2329
+ // KJC-TSK-0408: Undo solo si la HU tiene snapshot_sha en outcome.
2330
+ let parsedOutcome = null;
2331
+ try { parsedOutcome = typeof story.outcome === 'string' ? JSON.parse(story.outcome) : story.outcome; } catch { /* */ }
2332
+ const canUndo = parsedOutcome?.snapshot_sha && !parsedOutcome?.reverted;
2333
+ return canUndo ? `
2334
+ <button id="undo-hu-btn" class="control-btn"
2335
+ style="padding:6px 12px;border:1px solid var(--color-red,#ef4444);background:var(--bg-primary);color:var(--color-red,#ef4444);border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem"
2336
+ title="Deshacer cambios de esta HU: restaura los ficheros al snapshot pre-run y marca pending"
2337
+ onclick="undoHuChanges('${esc(story.id)}')">
2338
+ ⏪ Undo
2339
+ </button>
2340
+ ` : '';
2341
+ })()}
2342
+ <button class="modal__close" onclick="closeModal()">&times;</button>
2343
+ </div>
2344
+ </div>
2345
+
2346
+
2347
+ <div class="modal__section">
2348
+ <div class="modal__section-title">Original Text</div>
2349
+ <div class="modal__field-value">${esc(story.original_text || 'N/A')}</div>
2350
+ </div>
2351
+
2352
+ ${(story.coder_model || story.reviewer_model || canEdit) ? `
2353
+ <!-- KJC-TSK-0406: model routing per HU. Cada modelo es independiente
2354
+ y editable. Reviewer cross-provider del coder por defecto. -->
2355
+ <div class="modal__section">
2356
+ <div class="modal__section-title">Models</div>
2357
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:0.85rem">
2358
+ <div>
2359
+ <div class="modal__field-label">Coder</div>
2360
+ <div class="modal__field-value" style="font-family:monospace">
2361
+ ${esc(story.coder_model || 'auto')} ${story.coder_provider ? `<span style="color:var(--text-muted)">(${esc(story.coder_provider)})</span>` : ''}
2362
+ </div>
2363
+ ${canEdit ? `
2364
+ <input id="hu-coder-model" type="text" placeholder="modelo override"
2365
+ value="${esc(story.coder_model || '')}"
2366
+ style="margin-top:4px;width:100%;padding:4px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:0.8rem">
2367
+ ` : ''}
2368
+ </div>
2369
+ <div>
2370
+ <div class="modal__field-label">Reviewer (cross-provider)</div>
2371
+ <div class="modal__field-value" style="font-family:monospace">
2372
+ ${esc(story.reviewer_model || 'auto')} ${story.reviewer_provider ? `<span style="color:var(--text-muted)">(${esc(story.reviewer_provider)})</span>` : ''}
2373
+ </div>
2374
+ ${canEdit ? `
2375
+ <input id="hu-reviewer-model" type="text" placeholder="modelo override"
2376
+ value="${esc(story.reviewer_model || '')}"
2377
+ style="margin-top:4px;width:100%;padding:4px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:0.8rem">
2378
+ ` : ''}
2379
+ </div>
2380
+ </div>
2381
+ ${canEdit ? `
2382
+ <div style="margin-top:8px;display:flex;gap:6px;align-items:center">
2383
+ <button onclick="saveHuModels('${esc(story.id)}')" class="control-btn"
2384
+ style="padding:4px 10px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.8rem">
2385
+ 💾 Save models
2386
+ </button>
2387
+ <span style="color:var(--text-muted);font-size:0.75rem">vacío → re-asignar automáticamente</span>
2388
+ </div>
2389
+ ` : ''}
2390
+ </div>
2391
+ ` : ''}
2392
+
2393
+ ${story.certified_as ? `
2394
+ <div class="modal__section">
2395
+ <div class="modal__section-title">Certified Story</div>
2396
+ <div class="modal__field">
2397
+ <div class="modal__field-label">As a...</div>
2398
+ <div class="modal__field-value">${esc(story.certified_as)}</div>
2399
+ </div>
2400
+ <div class="modal__field">
2401
+ <div class="modal__field-label">I want to...</div>
2402
+ <div class="modal__field-value">${esc(story.certified_want || '--')}</div>
2403
+ </div>
2404
+ <div class="modal__field">
2405
+ <div class="modal__field-label">So that...</div>
2406
+ <div class="modal__field-value">${esc(story.certified_so_that || '--')}</div>
2407
+ </div>
2408
+ </div>
2409
+ ` : story.certified_want ? `
2410
+ <div class="modal__section">
2411
+ <div class="modal__section-title">Scope</div>
2412
+ <div class="modal__field-value" style="white-space: pre-wrap; font-size: 0.9rem; line-height: 1.5;">${esc(story.certified_want)}</div>
2413
+ </div>
2414
+ ` : ''}
2415
+
2416
+ ${story.quality_total !== null ? `
2417
+ <div class="modal__section">
2418
+ <div class="modal__section-title">Quality Score: ${story.quality_total}/60</div>
2419
+ <div class="modal__quality-grid">
2420
+ ${[1, 2, 3, 4, 5, 6].map((d, i) => {
2421
+ const val = story[`quality_d${d}`];
2422
+ return `
2423
+ <div class="modal__quality-dim">
2424
+ <div class="modal__quality-dim-label">${dimLabels[i]}</div>
2425
+ <div class="modal__quality-dim-value">${val !== null ? val + '/10' : '--'}</div>
2426
+ </div>
2427
+ `;
2428
+ }).join('')}
2429
+ </div>
2430
+ </div>
2431
+ ` : ''}
2432
+
2433
+ ${antipatterns.length > 0 ? `
2434
+ <div class="modal__section">
2435
+ <div class="modal__section-title">Antipatterns</div>
2436
+ ${antipatterns.map((a) => `<div class="story-card__antipattern" style="margin-bottom:4px">${esc(a)}</div>`).join('')}
2437
+ </div>
2438
+ ` : ''}
2439
+
2440
+ ${ac.length > 0 ? `
2441
+ <div class="modal__section">
2442
+ <div class="modal__section-title">Acceptance Criteria${story.ac_format ? ` (${esc(story.ac_format)})` : ''}</div>
2443
+ <ul class="modal__ac-list">
2444
+ ${ac.map((c) => {
2445
+ if (typeof c === 'string') return `<li class="modal__ac-item">${esc(c)}</li>`;
2446
+ if (c.given) return `<li class="modal__ac-item"><code>Given</code> ${esc(c.given)}<br><code>When</code> ${esc(c.when)}<br><code>Then</code> ${esc(c.then)}</li>`;
2447
+ return `<li class="modal__ac-item">${esc(JSON.stringify(c))}</li>`;
2448
+ }).join('')}
2449
+ </ul>
2450
+ </div>
2451
+ ` : ''}
2452
+
2453
+ ${tests.length === 0 ? `
2454
+ <div class="modal__section" style="border:1px solid var(--color-yellow,#eab308);background:rgba(234,179,8,0.08);padding:10px 12px;border-radius:var(--radius-sm)">
2455
+ <div class="modal__section-title" style="color:var(--color-yellow,#eab308)">⚠ Missing test contract</div>
2456
+ <div style="font-size:0.85rem;line-height:1.5;margin-top:4px">
2457
+ This HU has no acceptance_tests declared. The tests-first pipeline (v2.7.5)
2458
+ refuses to run HUs without an executable contract. Click ✎ Edit above and
2459
+ add at least one test — a <code>shell</code> command that exits 0 on pass,
2460
+ or a <code>gherkin</code> Given/When/Then spec.
2461
+ </div>
2462
+ </div>
2463
+ ` : `
2464
+ <div class="modal__section">
2465
+ <div class="modal__section-title">Acceptance Tests (${tests.length})</div>
2466
+ <ul class="modal__ac-list">
2467
+ ${tests.map((t) => {
2468
+ // Legacy form: plain string — render as shell.
2469
+ if (typeof t === 'string') {
2470
+ return `<li class="modal__ac-item"><span style="font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-right:8px">shell</span><code>${esc(t)}</code></li>`;
2471
+ }
2472
+ // v2.7.5 structured form: { type, content, file? }
2473
+ if (t && typeof t === 'object' && typeof t.content === 'string') {
2474
+ const type = t.type === 'gherkin' ? 'gherkin' : 'shell';
2475
+ const badgeColor = type === 'gherkin' ? 'var(--color-blue,#3b82f6)' : 'var(--text-muted)';
2476
+ const fileBit = t.file ? ` <span style="color:var(--text-muted);font-size:0.75rem;margin-left:8px">→ ${esc(t.file)}</span>` : '';
2477
+ if (type === 'gherkin') {
2478
+ return `<li class="modal__ac-item"><span style="font-size:0.7rem;color:${badgeColor};text-transform:uppercase;letter-spacing:0.5px;margin-right:8px">gherkin</span>${fileBit}<pre style="white-space:pre-wrap;margin:4px 0 0 0;font-family:inherit;font-size:0.9rem;line-height:1.5">${esc(t.content)}</pre></li>`;
2479
+ }
2480
+ return `<li class="modal__ac-item"><span style="font-size:0.7rem;color:${badgeColor};text-transform:uppercase;letter-spacing:0.5px;margin-right:8px">shell</span>${fileBit}<code>${esc(t.content)}</code></li>`;
2481
+ }
2482
+ // Fallback for anything else: show the raw JSON so the user
2483
+ // can diagnose malformed entries.
2484
+ return `<li class="modal__ac-item"><code>${esc(JSON.stringify(t))}</code></li>`;
2485
+ }).join('')}
2486
+ </ul>
2487
+ </div>
2488
+ `}
2489
+
2490
+ ${ctxRequests.length > 0 ? `
2491
+ <div class="modal__section">
2492
+ <div class="modal__section-title">Context Requests (${ctxRequests.length})</div>
2493
+ ${ctxRequests.map((cr) => `
2494
+ <div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px;margin-bottom:6px;">
2495
+ <div style="font-size:0.8rem;color:var(--color-yellow);">${esc(cr.question || 'Fields needed: ' + (cr.fields_needed || ''))}</div>
2496
+ ${cr.answer ? `<div style="font-size:0.8rem;color:var(--color-green);margin-top:4px;">${esc(cr.answer)}</div>` : ''}
2497
+ </div>
2498
+ `).join('')}
2499
+ </div>
2500
+ ` : ''}
2501
+
2502
+ <details class="modal__section" style="margin-top:8px">
2503
+ <summary style="cursor:pointer;font-weight:600;font-size:0.85rem;color:var(--text-muted);padding:6px 0">
2504
+ Metadata
2505
+ </summary>
2506
+ <div style="padding-top:6px">
2507
+ <div class="modal__field"><span class="modal__field-label">Project:</span> ${esc(story.project_id)}</div>
2508
+ ${story.spec_section ? `<div class="modal__field"><span class="modal__field-label">Implements SPEC:</span> §${esc(story.spec_section)}</div>` : ''}
2509
+ <div class="modal__field"><span class="modal__field-label">Session:</span> ${esc(story.session_id || '--')}</div>
2510
+ <div class="modal__field"><span class="modal__field-label">Created:</span> ${esc(story.created_at || '--')}</div>
2511
+ <div class="modal__field"><span class="modal__field-label">Updated:</span> ${esc(story.updated_at || '--')}</div>
2512
+ ${story.certified_at ? `<div class="modal__field"><span class="modal__field-label">Certified at:</span> ${esc(story.certified_at)}</div>` : ''}
2513
+ </div>
2514
+ </details>
2515
+ `;
2516
+
2517
+ // Wire the Edit button — stays within the modal so we don't flash
2518
+ // a re-fetch; the edit form overwrites innerHTML in place.
2519
+ const editBtn = document.getElementById('edit-hu-btn');
2520
+ if (editBtn) editBtn.addEventListener('click', () => renderStoryEditForm(story));
2521
+ } catch (err) {
2522
+ content.innerHTML = `<div class="modal__header"><div class="modal__title">Error</div><button class="modal__close" onclick="closeModal()">&times;</button></div><p>${esc(err.message)}</p>`;
2523
+ }
2524
+ }
2525
+
2526
+ /**
2527
+ * Swap the story modal into an inline edit form. Accepts the full story
2528
+ * record that `showStoryDetail` just rendered so we don't re-fetch —
2529
+ * the user's intent is "edit what I'm looking at".
2530
+ *
2531
+ * Cancel restores the read-only view via `showStoryDetail`; Save posts a
2532
+ * PATCH and, on success, re-opens in read-only with the refreshed row.
2533
+ *
2534
+ * @param {object} story
2535
+ */
2536
+ function renderStoryEditForm(story) {
2537
+ const content = document.getElementById('modal-content');
2538
+ const initials = projectInitialsCache[story.project_id] || 'kj';
2539
+ const shortId = shortStoryId(story, initials);
2540
+
2541
+ const rawAc = story.acceptance_criteria ? JSON.parse(story.acceptance_criteria) : [];
2542
+ // Represent AC as text: plain strings go verbatim, Gherkin objects
2543
+ // collapse to "Given … | When … | Then …". On save we parse back —
2544
+ // if the line matches the pattern we treat it as Gherkin, otherwise
2545
+ // a free-form string.
2546
+ const acInitial = rawAc
2547
+ .map((c) => typeof c === 'string' ? c : (c.given ? `Given ${c.given} | When ${c.when} | Then ${c.then}` : JSON.stringify(c)))
2548
+ .join('\n');
2549
+
2550
+ // Tests-first editor: one block per test with a type selector
2551
+ // (shell | gherkin), the content textarea, optional file path, and
2552
+ // a remove button. Save collects them into the structured v2.7.5
2553
+ // array shape. Plain strings in the existing data are treated as
2554
+ // legacy shell tests and auto-upgraded to the structured form.
2555
+ const rawTests = story.acceptance_tests ? JSON.parse(story.acceptance_tests) : [];
2556
+ const testsInitial = rawTests.map((t) => {
2557
+ if (typeof t === 'string') return { type: 'shell', content: t, file: '' };
2558
+ if (t && typeof t === 'object') {
2559
+ return {
2560
+ type: t.type === 'gherkin' ? 'gherkin' : 'shell',
2561
+ content: typeof t.content === 'string' ? t.content : JSON.stringify(t),
2562
+ file: typeof t.file === 'string' ? t.file : '',
2563
+ };
2564
+ }
2565
+ return { type: 'shell', content: String(t ?? ''), file: '' };
2566
+ });
2567
+
2568
+ const scopeInitial = story.original_text || story.certified_want || '';
2569
+ const TASK_TYPES = ['sw', 'infra', 'doc', 'add-tests', 'refactor'];
2570
+
2571
+ content.innerHTML = `
2572
+ <div class="modal__header">
2573
+ <div>
2574
+ <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>
2575
+ <div class="modal__subtitle" style="font-size:0.75rem;color:var(--text-muted);font-family:monospace;margin-top:2px">${esc(story.id)}</div>
2576
+ </div>
2577
+ <button class="modal__close" onclick="closeModal()">&times;</button>
2578
+ </div>
2579
+
2580
+ <form id="hu-edit-form" onsubmit="return false" style="display:flex;flex-direction:column;gap:14px;padding:8px 0">
2581
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
2582
+ <span style="color:var(--text-muted)">Title</span>
2583
+ <input type="text" id="edit-title" value="${esc(story.title || '')}"
2584
+ style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.95rem"
2585
+ maxlength="200" required>
2586
+ </label>
2587
+
2588
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
2589
+ <span style="color:var(--text-muted)">Scope</span>
2590
+ <textarea id="edit-scope" rows="4"
2591
+ 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>
2592
+ </label>
2593
+
2594
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
2595
+ <span style="color:var(--text-muted)">Task type</span>
2596
+ <select id="edit-task-type"
2597
+ style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
2598
+ ${TASK_TYPES.map((t) => `<option value="${t}">${t}</option>`).join('')}
2599
+ </select>
2600
+ </label>
2601
+
2602
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
2603
+ <span style="color:var(--text-muted)">
2604
+ Acceptance criteria — one per line.
2605
+ Gherkin: <code>Given X | When Y | Then Z</code>
2606
+ </span>
2607
+ <textarea id="edit-ac" rows="6"
2608
+ 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>
2609
+ </label>
2610
+
2611
+ <fieldset style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;margin:0">
2612
+ <legend style="color:var(--text-muted);font-size:0.85rem;padding:0 6px">
2613
+ Acceptance tests — the contract the coder must satisfy
2614
+ </legend>
2615
+ <div id="edit-tests-list" style="display:flex;flex-direction:column;gap:8px"></div>
2616
+ <button type="button" id="edit-tests-add"
2617
+ 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">
2618
+ + Add test
2619
+ </button>
2620
+ </fieldset>
2621
+
2622
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;padding-top:12px;border-top:1px solid var(--border)">
2623
+ <button type="button" id="edit-cancel" class="control-btn"
2624
+ style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
2625
+ Cancel
2626
+ </button>
2627
+ <button type="button" id="edit-save" class="control-btn"
2628
+ style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
2629
+ Save
2630
+ </button>
2631
+ </div>
2632
+ </form>
2633
+ `;
2634
+
2635
+ // Tests editor state + renderers. Kept on the function scope (not
2636
+ // on window) so reopening the form gives a fresh copy.
2637
+ let testRows = [...testsInitial];
2638
+ const listEl = document.getElementById('edit-tests-list');
2639
+
2640
+ function renderTestsEditor() {
2641
+ listEl.innerHTML = testRows.map((t, i) => `
2642
+ <div class="edit-test-row" data-idx="${i}"
2643
+ 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)">
2644
+ <select data-field="type"
2645
+ 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">
2646
+ <option value="shell"${t.type === 'shell' ? ' selected' : ''}>shell</option>
2647
+ <option value="gherkin"${t.type === 'gherkin' ? ' selected' : ''}>gherkin</option>
2648
+ </select>
2649
+ <div style="display:flex;flex-direction:column;gap:4px">
2650
+ <textarea data-field="content" rows="${t.type === 'gherkin' ? 3 : 2}"
2651
+ placeholder="${t.type === 'gherkin' ? 'Given …\\nWhen …\\nThen …' : 'npx vitest run test/foo.test.js'}"
2652
+ 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>
2653
+ <input type="text" data-field="file" placeholder="Optional: file path (e.g. tests/login.test.ts)" value="${esc(t.file || '')}"
2654
+ 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">
2655
+ </div>
2656
+ <button type="button" data-field="remove" title="Remove this test"
2657
+ 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>
2658
+ </div>
2659
+ `).join('');
2660
+
2661
+ // Bind the edit events. We re-bind on every render — cheap, and
2662
+ // keeps the state → DOM mapping simple.
2663
+ listEl.querySelectorAll('.edit-test-row').forEach((row) => {
2664
+ const idx = Number(row.dataset.idx);
2665
+ row.querySelector('[data-field="type"]').addEventListener('change', (e) => {
2666
+ testRows[idx].type = e.target.value;
2667
+ renderTestsEditor(); // re-render so placeholder updates
2668
+ });
2669
+ row.querySelector('[data-field="content"]').addEventListener('input', (e) => {
2670
+ testRows[idx].content = e.target.value;
2671
+ });
2672
+ row.querySelector('[data-field="file"]').addEventListener('input', (e) => {
2673
+ testRows[idx].file = e.target.value;
2674
+ });
2675
+ row.querySelector('[data-field="remove"]').addEventListener('click', () => {
2676
+ testRows.splice(idx, 1);
2677
+ renderTestsEditor();
2678
+ });
2679
+ });
2680
+ }
2681
+
2682
+ renderTestsEditor();
2683
+
2684
+ document.getElementById('edit-tests-add').addEventListener('click', () => {
2685
+ testRows.push({ type: 'shell', content: '', file: '' });
2686
+ renderTestsEditor();
2687
+ });
2688
+
2689
+ document.getElementById('edit-cancel').addEventListener('click', () => showStoryDetail(story.id));
2690
+ document.getElementById('edit-save').addEventListener('click', () => saveStoryEdits(story, () => testRows));
2691
+ }
2692
+
2693
+ /**
2694
+ * Collect the form values, compute the diff against the story as it
2695
+ * was on modal open, and PATCH only the changed fields. Empty diff is
2696
+ * a no-op (close out of edit mode without an API call).
2697
+ * @param {object} story
2698
+ */
2699
+ async function saveStoryEdits(story, getTestRows) {
2700
+ const title = document.getElementById('edit-title').value.trim();
2701
+ const scope = document.getElementById('edit-scope').value;
2702
+ const taskType = document.getElementById('edit-task-type').value;
2703
+ const acRaw = document.getElementById('edit-ac').value;
2704
+
2705
+ if (!title) {
2706
+ await showError('Title cannot be empty.', { title: 'Invalid input' });
2707
+ return;
2708
+ }
2709
+
2710
+ // Parse AC: one per line; a line with "Given X | When Y | Then Z"
2711
+ // (case-insensitive) becomes Gherkin; everything else stays a string.
2712
+ // Blank lines are dropped.
2713
+ const acceptance_criteria = acRaw.split('\n')
2714
+ .map((line) => line.trim())
2715
+ .filter(Boolean)
2716
+ .map((line) => {
2717
+ const m = /^Given\s+(.*?)\s*\|\s*When\s+(.*?)\s*\|\s*Then\s+(.*)$/i.exec(line);
2718
+ if (m) return { given: m[1].trim(), when: m[2].trim(), then: m[3].trim() };
2719
+ return line;
2720
+ });
2721
+
2722
+ // Collect the structured acceptance_tests from the editor state,
2723
+ // dropping rows the user left empty.
2724
+ const testRows = typeof getTestRows === 'function' ? getTestRows() : [];
2725
+ const acceptance_tests = testRows
2726
+ .map((t) => ({
2727
+ type: t.type === 'gherkin' ? 'gherkin' : 'shell',
2728
+ content: (t.content || '').trim(),
2729
+ file: (t.file || '').trim(),
2730
+ }))
2731
+ .filter((t) => t.content.length > 0)
2732
+ .map((t) => (t.file ? t : { type: t.type, content: t.content }));
2733
+
2734
+ // Diff against the original so we don't send untouched fields and
2735
+ // let the server's COALESCE keep existing values.
2736
+ const patch = {};
2737
+ const originalScope = story.original_text || story.certified_want || '';
2738
+ if (title !== (story.title || '')) patch.title = title;
2739
+ if (scope !== originalScope) patch.scope = scope;
2740
+ if (taskType && taskType !== 'sw') patch.task_type = taskType;
2741
+ const prevAcStr = story.acceptance_criteria || '[]';
2742
+ const nextAcStr = JSON.stringify(acceptance_criteria);
2743
+ if (nextAcStr !== prevAcStr) patch.acceptance_criteria = acceptance_criteria;
2744
+ const prevTestsStr = story.acceptance_tests || '[]';
2745
+ const nextTestsStr = JSON.stringify(acceptance_tests);
2746
+ if (nextTestsStr !== prevTestsStr) patch.acceptance_tests = acceptance_tests;
2747
+
2748
+ if (Object.keys(patch).length === 0) {
2749
+ showStoryDetail(story.id);
2750
+ return;
2751
+ }
2752
+
2753
+ try {
2754
+ const res = await fetch(`/api/stories/${encodeURIComponent(story.id)}`, {
2755
+ method: 'PATCH',
2756
+ headers: { 'Content-Type': 'application/json' },
2757
+ body: JSON.stringify(patch),
2758
+ });
2759
+ if (!res.ok) {
2760
+ if (res.status === 404) {
2761
+ await showError(
2762
+ 'The board server does not recognise the extended PATCH endpoint.\n\n'
2763
+ + 'Restart it to pick up v2.7.5+:\n\n'
2764
+ + ' kj board stop && kj board start',
2765
+ { title: 'Board out of date' }
2766
+ );
2767
+ return;
2768
+ }
2769
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
2770
+ await showError(msg, { title: 'Could not save HU' });
2771
+ return;
2772
+ }
2773
+ await renderBoard(); // card shows new title/counts
2774
+ await showStoryDetail(story.id); // reopen modal with fresh data
2775
+ } catch (err) {
2776
+ await showError(err.message, { title: 'Could not save HU' });
2777
+ }
2778
+ }
2779
+
2780
+ /**
2781
+ * Shows the session detail modal.
2782
+ * @param {string} sessionId
2783
+ */
2784
+ async function showSessionDetail(sessionId) {
2785
+ const backdrop = document.getElementById('modal-backdrop');
2786
+ const content = document.getElementById('modal-content');
2787
+ backdrop.classList.remove('hidden');
2788
+
2789
+ content.innerHTML = '<div class="loading"><div class="loading__spinner"></div></div>';
2790
+
2791
+ try {
2792
+ const session = await api(`/api/sessions/${encodeURIComponent(sessionId)}`);
2793
+ const checkpoints = session.checkpoints || [];
2794
+ const config = session.config_snapshot || {};
2795
+ const budget = session.budget || {};
2796
+ const startTime = session.created_at ? new Date(session.created_at).getTime() : 0;
2797
+
2798
+ const __label = formatSessionLabel(session);
2799
+ content.innerHTML = `
2800
+ <div class="modal__header">
2801
+ <div>
2802
+ <div class="modal__title">${esc(__label.title)}</div>
2803
+ <div class="modal__subtitle session-card__id-chip" title="Use with kj resume &lt;id&gt;">${esc(session.id)}</div>
2804
+ <span class="session-card__status session-status--${session.status}">${esc(session.status)}</span>
2805
+ </div>
2806
+ <button class="modal__close" onclick="closeModal()">&times;</button>
2807
+ </div>
2808
+
2809
+ <div class="modal__section">
2810
+ <div class="modal__section-title">Task</div>
2811
+ <div class="modal__field-value" style="font-size:0.85rem">${esc(session.task || 'N/A')}</div>
2812
+ </div>
2813
+
2814
+ <div class="modal__section">
2815
+ <div class="modal__section-title">Overview</div>
2816
+ <div class="stats-grid" style="margin-bottom:0">
2817
+ <div class="stat-card">
2818
+ <div class="stat-card__value">${session.iterations || 0}</div>
2819
+ <div class="stat-card__label">Iterations</div>
2820
+ </div>
2821
+ <div class="stat-card">
2822
+ <div class="stat-card__value">${formatDuration(session.duration_ms)}</div>
2823
+ <div class="stat-card__label">Duration</div>
2824
+ </div>
2825
+ <div class="stat-card">
2826
+ <div class="stat-card__value ${session.approved ? 'stat-card__value--green' : 'stat-card__value--yellow'}">${session.approved ? 'Yes' : 'No'}</div>
2827
+ <div class="stat-card__label">Approved</div>
2828
+ </div>
2829
+ </div>
2830
+ </div>
2831
+
2832
+ ${config.coder || config.reviewer ? `
2833
+ <div class="modal__section">
2834
+ <div class="modal__section-title">Configuration</div>
2835
+ <div style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-secondary)">
2836
+ ${config.coder ? `Coder: ${esc(config.coder)}` : ''}
2837
+ ${config.reviewer ? ` | Reviewer: ${esc(config.reviewer)}` : ''}
2838
+ </div>
2839
+ </div>
2840
+ ` : ''}
2841
+
2842
+ ${checkpoints.length > 0 ? `
2843
+ <div class="modal__section">
2844
+ <div class="modal__section-title">Timeline (${checkpoints.length} checkpoints)</div>
2845
+ <div class="timeline">
2846
+ ${checkpoints.map((cp) => {
2847
+ const elapsed = cp.at && startTime ? formatDuration(new Date(cp.at).getTime() - startTime) : '';
2848
+ const isOk = cp.ok === true || cp.approved === true;
2849
+ const isFail = cp.ok === false || cp.approved === false;
2850
+ const itemClass = isOk ? 'timeline__item--ok' : isFail ? 'timeline__item--fail' : 'timeline__item--info';
2851
+
2852
+ let detail = '';
2853
+ if (cp.note) detail = cp.note;
2854
+ else if (cp.approved !== undefined) detail = cp.approved ? 'APPROVED' : `REJECTED (${cp.blocking_issues || 0} issues)`;
2855
+ else if (cp.reason) detail = cp.reason;
2856
+ else if (cp.ok !== undefined) detail = cp.ok ? 'PASSED' : 'FAILED';
2857
+ if (cp.provider) detail += ` [${cp.provider}]`;
2858
+
2859
+ return `
2860
+ <div class="timeline__item ${itemClass}">
2861
+ <span class="timeline__time">${elapsed}</span>
2862
+ <div class="timeline__stage">[${esc(cp.stage)}] iter ${cp.iteration || 0}</div>
2863
+ <div class="timeline__detail">${esc(detail)}</div>
2864
+ </div>
2865
+ `;
2866
+ }).join('')}
2867
+ </div>
2868
+ </div>
2869
+ ` : ''}
2870
+
2871
+ ${budget.total_cost_usd !== undefined ? `
2872
+ <div class="modal__section">
2873
+ <div class="modal__section-title">Budget</div>
2874
+ <div style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-secondary)">
2875
+ Tokens: ${budget.total_tokens || 0} | Cost: $${(budget.total_cost_usd || 0).toFixed(4)}
2876
+ </div>
2877
+ </div>
2878
+ ` : ''}
2879
+
2880
+ <details class="modal__section" style="margin-top:8px">
2881
+ <summary style="cursor:pointer;font-weight:600;font-size:0.85rem;color:var(--text-muted);padding:6px 0">
2882
+ Metadata
2883
+ </summary>
2884
+ <div style="padding-top:6px">
2885
+ <div class="modal__field"><span class="modal__field-label">Project:</span> ${esc(session.project_id)}</div>
2886
+ <div class="modal__field"><span class="modal__field-label">Created:</span> ${esc(session.created_at || '--')}</div>
2887
+ <div class="modal__field"><span class="modal__field-label">Updated:</span> ${esc(session.updated_at || '--')}</div>
2888
+ </div>
2889
+ </details>
2890
+ `;
2891
+ } catch (err) {
2892
+ content.innerHTML = `<div class="modal__header"><div class="modal__title">Error</div><button class="modal__close" onclick="closeModal()">&times;</button></div><p>${esc(err.message)}</p>`;
2893
+ }
2894
+ }
2895
+
2896
+ /**
2897
+ * Closes the modal.
2898
+ */
2899
+ function closeModal() {
2900
+ document.getElementById('modal-backdrop').classList.add('hidden');
2901
+ }
2902
+
2903
+ // ---- Native dialog helpers ----
2904
+ //
2905
+ // Project convention (and common sense): no window.alert / confirm / prompt.
2906
+ // These use the browser's built-in modal chrome, steal focus, block script
2907
+ // execution, and look foreign on every site. We use <dialog> instead —
2908
+ // same blocking semantics without any of the downsides, and it composes
2909
+ // with our own CSS variables so it matches the rest of the board.
2910
+ //
2911
+ // `showError` is the replacement for alert(); `showConfirm` for confirm().
2912
+
2913
+ /**
2914
+ * Lazily create (or reuse) the shared <dialog> singleton, so we don't
2915
+ * leak a new DOM node on every error.
2916
+ * @returns {HTMLDialogElement}
2917
+ */
2918
+ function ensureDialog() {
2919
+ let dlg = document.getElementById('app-dialog');
2920
+ if (dlg) return dlg;
2921
+ dlg = document.createElement('dialog');
2922
+ dlg.id = 'app-dialog';
2923
+ // Position explicitly: showModal() defaults to centered, but when
2924
+ // inner content (a wide textarea, the command launcher's form,
2925
+ // etc.) pushes past the dialog's natural width Chrome falls back
2926
+ // to top-left. Pinning position+transform here makes centering
2927
+ // robust regardless of inner content size. We also reset the
2928
+ // browser's default 1em margin on <dialog> which would otherwise
2929
+ // offset the transform.
2930
+ dlg.style.cssText = [
2931
+ 'position: fixed',
2932
+ 'top: 50%',
2933
+ 'left: 50%',
2934
+ 'transform: translate(-50%, -50%)',
2935
+ 'margin: 0',
2936
+ 'border: 1px solid var(--border)',
2937
+ 'border-radius: var(--radius-sm)',
2938
+ 'padding: 0',
2939
+ 'min-width: 320px',
2940
+ 'max-width: min(720px, 92vw)',
2941
+ 'max-height: 90vh',
2942
+ 'overflow: auto',
2943
+ 'background: var(--bg-secondary)',
2944
+ 'color: var(--text)',
2945
+ 'box-shadow: 0 10px 40px rgba(0,0,0,0.45)',
2946
+ ].join(';');
2947
+ // Inject a backdrop dim rule once. Inline styles can't target the
2948
+ // ::backdrop pseudo-element, so we use a one-off <style> tag.
2949
+ if (!document.getElementById('app-dialog-style')) {
2950
+ const styleEl = document.createElement('style');
2951
+ styleEl.id = 'app-dialog-style';
2952
+ styleEl.textContent =
2953
+ '#app-dialog::backdrop { background: rgba(0,0,0,0.55); }';
2954
+ document.head.appendChild(styleEl);
2955
+ }
2956
+ document.body.appendChild(dlg);
2957
+ return dlg;
2958
+ }
2959
+
2960
+ /**
2961
+ * Show a blocking error dialog. Returns a promise that resolves when the
2962
+ * user dismisses the dialog (Esc, backdrop click, or OK button).
2963
+ * @param {string} message
2964
+ * @param {object} [opts]
2965
+ * @param {string} [opts.title] - defaults to "Error"
2966
+ */
2967
+ function showError(message, opts = {}) {
2968
+ return new Promise((resolve) => {
2969
+ const dlg = ensureDialog();
2970
+ const title = opts.title || 'Error';
2971
+ dlg.innerHTML = `
2972
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);
2973
+ font-weight:600;color:var(--color-red,#ef4444)">
2974
+ ${esc(title)}
2975
+ </div>
2976
+ <div style="padding:16px 18px;font-size:0.9rem;line-height:1.5;
2977
+ white-space:pre-wrap">${esc(message)}</div>
2978
+ <div style="padding:12px 18px;border-top:1px solid var(--border);
2979
+ text-align:right">
2980
+ <button id="app-dialog-ok" class="control-btn"
2981
+ style="padding:6px 16px;border:1px solid var(--border);
2982
+ background:var(--bg-primary);color:var(--text);
2983
+ border-radius:var(--radius-sm);cursor:pointer">
2984
+ OK
2985
+ </button>
2986
+ </div>
2987
+ `;
2988
+ const done = () => {
2989
+ if (dlg.open) dlg.close();
2990
+ resolve();
2991
+ };
2992
+ dlg.addEventListener('close', done, { once: true });
2993
+ dlg.querySelector('#app-dialog-ok').addEventListener('click', done, { once: true });
2994
+ dlg.showModal();
2995
+ });
2996
+ }
2997
+
2998
+ /**
2999
+ * Five-view quick reference modal (KJC-TSK-0372 — board polish #4).
3000
+ *
3001
+ * Triggered by the `?` button in the header. The native `title=`
3002
+ * attributes on each tab cover the hover-1s tooltip case (browsers
3003
+ * surface those automatically). This modal is the deeper "what does
3004
+ * each view do" answer for users who arrive cold.
3005
+ *
3006
+ * Returns a promise that resolves when the user dismisses the modal.
3007
+ */
3008
+ function showHelp() {
3009
+ return new Promise((resolve) => {
3010
+ const dlg = ensureDialog();
3011
+ const sections = [
3012
+ ['📋 Board',
3013
+ 'Kanban with every HU of the selected project, grouped by status (Pending → Certified → Done). Click a card to open its detail panel; cards highlight when blocked-by another HU.'],
3014
+ ['🌐 Graph',
3015
+ 'Dependency graph of the selected project — who blocks whom. Useful before scheduling work to spot critical paths or orphan HUs.'],
3016
+ ['📊 Dashboard',
3017
+ 'Per-project landing: stats grid (total / certified / done) + project list. Click a project card to focus the kanban / graph on that project.'],
3018
+ ['📚 Sessions',
3019
+ 'Every kj run that has touched the board, newest first. Filter by project; click a session to inspect its iterations, commits and checkpoints.'],
3020
+ ['⚙️ Pipeline',
3021
+ 'Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs. Opens in a separate page since the data is per-run, not per-project.'],
3022
+ ];
3023
+ const sectionHtml = sections
3024
+ .map(([title, body]) => `
3025
+ <div style="padding:10px 0;border-bottom:1px solid var(--border)">
3026
+ <div style="font-weight:600;color:var(--accent-purple);margin-bottom:4px">${esc(title)}</div>
3027
+ <div style="font-size:0.85rem;line-height:1.5;color:var(--text-secondary)">${esc(body)}</div>
3028
+ </div>`)
3029
+ .join('');
3030
+ dlg.innerHTML = `
3031
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600">
3032
+ HU Board — what does each view do?
3033
+ </div>
3034
+ <div style="padding:8px 18px 4px 18px">${sectionHtml}</div>
3035
+ <div style="padding:10px 18px;border-top:1px solid var(--border);
3036
+ font-size:0.78rem;color:var(--text-muted)">
3037
+ Tip: hover any tab in the header for ~1 second to see a one-line summary.
3038
+ </div>
3039
+ <div style="padding:12px 18px;border-top:1px solid var(--border);text-align:right">
3040
+ <button id="app-dialog-close" class="control-btn"
3041
+ style="padding:6px 16px;border:1px solid var(--border);
3042
+ background:var(--bg-primary);color:var(--text);
3043
+ border-radius:var(--radius-sm);cursor:pointer">
3044
+ Close
3045
+ </button>
3046
+ </div>
3047
+ `;
3048
+ const done = () => {
3049
+ if (dlg.open) dlg.close();
3050
+ resolve();
3051
+ };
3052
+ dlg.addEventListener('close', done, { once: true });
3053
+ dlg.querySelector('#app-dialog-close').addEventListener('click', done, { once: true });
3054
+ dlg.showModal();
3055
+ });
3056
+ }
3057
+
3058
+ /**
3059
+ * Show a blocking confirm dialog. Resolves to true when the user clicks
3060
+ * the primary action, false otherwise (cancel / Esc / backdrop click).
3061
+ * @param {string} message
3062
+ * @param {object} [opts]
3063
+ * @param {string} [opts.title]
3064
+ * @param {string} [opts.okLabel]
3065
+ * @param {string} [opts.cancelLabel]
3066
+ * @param {boolean} [opts.destructive] - red OK button for deletions
3067
+ * @returns {Promise<boolean>}
3068
+ */
3069
+ function showConfirm(message, opts = {}) {
3070
+ return new Promise((resolve) => {
3071
+ const dlg = ensureDialog();
3072
+ const title = opts.title || 'Confirm';
3073
+ const okLabel = opts.okLabel || 'OK';
3074
+ const cancelLabel = opts.cancelLabel || 'Cancel';
3075
+ const okColor = opts.destructive ? 'var(--color-red,#ef4444)' : 'var(--color-green)';
3076
+ dlg.innerHTML = `
3077
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600">
3078
+ ${esc(title)}
3079
+ </div>
3080
+ <div style="padding:16px 18px;font-size:0.9rem;line-height:1.5;
3081
+ white-space:pre-wrap">${esc(message)}</div>
3082
+ <div style="padding:12px 18px;border-top:1px solid var(--border);
3083
+ display:flex;justify-content:flex-end;gap:8px">
3084
+ <button id="app-dialog-cancel" class="control-btn"
3085
+ style="padding:6px 14px;border:1px solid var(--border);
3086
+ background:var(--bg-primary);color:var(--text);
3087
+ border-radius:var(--radius-sm);cursor:pointer">
3088
+ ${esc(cancelLabel)}
3089
+ </button>
3090
+ <button id="app-dialog-ok" class="control-btn"
3091
+ style="padding:6px 14px;border:none;background:${okColor};
3092
+ color:#fff;border-radius:var(--radius-sm);cursor:pointer">
3093
+ ${esc(okLabel)}
3094
+ </button>
3095
+ </div>
3096
+ `;
3097
+ let answer = false;
3098
+ const finish = () => {
3099
+ if (dlg.open) dlg.close();
3100
+ resolve(answer);
3101
+ };
3102
+ dlg.addEventListener('close', finish, { once: true });
3103
+ dlg.querySelector('#app-dialog-ok').addEventListener('click', () => { answer = true; finish(); }, { once: true });
3104
+ dlg.querySelector('#app-dialog-cancel').addEventListener('click', () => { answer = false; finish(); }, { once: true });
3105
+ dlg.showModal();
3106
+ });
3107
+ }
3108
+
3109
+ // ---- Navigation ----
3110
+
3111
+ /**
3112
+ * Navigates to a specific view.
3113
+ * @param {string} view - 'dashboard', 'board', or 'sessions'
3114
+ */
3115
+ function navigate(view) {
3116
+ currentView = view;
3117
+ window.location.hash = selectedProject ? `${view}/${selectedProject}` : view;
3118
+
3119
+ // Update active nav button
3120
+ document.querySelectorAll('.nav-btn').forEach((btn) => {
3121
+ btn.classList.toggle('active', btn.dataset.view === view);
3122
+ });
3123
+
3124
+ render();
3125
+ }
3126
+
3127
+ /**
3128
+ * Selects a project and navigates to the board view.
3129
+ * @param {string} projectId
3130
+ */
3131
+ function selectProject(projectId) {
3132
+ selectedProject = projectId;
3133
+ document.getElementById('project-select').value = projectId;
3134
+ navigate('board');
3135
+ }
3136
+
3137
+ /**
3138
+ * Renders the current view.
3139
+ */
3140
+ function render() {
3141
+ switch (currentView) {
3142
+ case 'dashboard': return renderDashboard();
3143
+ case 'board': return renderBoard();
3144
+ case 'sessions': return renderSessions();
3145
+ case 'graph': return renderGraph();
3146
+ default: return renderDashboard();
3147
+ }
3148
+ }
3149
+
3150
+ /**
3151
+ * Populates the project selector dropdown.
3152
+ */
3153
+ async function populateProjectSelect() {
3154
+ try {
3155
+ const projects = await api('/api/projects');
3156
+ const select = document.getElementById('project-select');
3157
+ // Which project should appear pre-selected? Order:
3158
+ // 1. scopedProjectSlug (URL is /p/<slug>) — locked, never changes
3159
+ // 2. selectedProject (route state, ej. #board/<slug>)
3160
+ // 3. "" — "All Projects"
3161
+ // PR-D: the previous version cleared innerHTML and never marked
3162
+ // any option as selected, so the dropdown showed "All Projects"
3163
+ // even when scoped to one. handleRoute() set .value separately
3164
+ // BUT the value was lost in the race because the matching option
3165
+ // didn't exist yet when handleRoute ran. Setting .selected on
3166
+ // the option directly avoids the race.
3167
+ const desired = scopedProjectSlug || selectedProject || '';
3168
+ select.innerHTML = '';
3169
+ const all = document.createElement('option');
3170
+ all.value = '';
3171
+ all.textContent = 'All Projects';
3172
+ if (desired === '') all.selected = true;
3173
+ select.appendChild(all);
3174
+ for (const p of projects) {
3175
+ const opt = document.createElement('option');
3176
+ opt.value = p.id;
3177
+ opt.textContent = p.name || p.id;
3178
+ if (p.id === desired) opt.selected = true;
3179
+ select.appendChild(opt);
3180
+ }
3181
+ // Final guard: if `desired` did not match any rendered option
3182
+ // (project not on the board yet), still set the value so
3183
+ // handleRoute's expectation holds.
3184
+ if (desired) select.value = desired;
3185
+ } catch {
3186
+ // Silently fail — project list will be empty
3187
+ }
3188
+ }
3189
+
3190
+ /**
3191
+ * Parses the hash route and renders.
3192
+ */
3193
+ function handleRoute() {
3194
+ // Board-first: the default landing view is always the Kanban. Dashboard
3195
+ // is only shown when the user explicitly navigates to `#dashboard`.
3196
+ const hash = window.location.hash.slice(1) || 'board';
3197
+ const parts = hash.split('/');
3198
+ // In scoped mode the project is locked — the hash controls the view
3199
+ // only. `board/<slug>` becomes `board` and the slug stays fixed.
3200
+ currentView = parts[0] || 'board';
3201
+ selectedProject = scopedProjectSlug || parts[1] || '';
3202
+
3203
+ document.querySelectorAll('.nav-btn').forEach((btn) => {
3204
+ btn.classList.toggle('active', btn.dataset.view === currentView);
3205
+ });
3206
+
3207
+ document.getElementById('project-select').value = selectedProject;
3208
+ render();
3209
+ }
3210
+
3211
+ // ---- Command launcher modal ----
3212
+ //
3213
+ // Opens a small form inside the existing <dialog> singleton with a
3214
+ // command picker (plan / architect / clean) and the appropriate
3215
+ // fields per command. Submit POSTs to /api/commands/:command, the
3216
+ // server spawns a detached `kj` child, and we open the existing log
3217
+ // viewer (re-uses openLogViewer's tail loop, just pointed at the
3218
+ // new generic /api/runs/:commandId/log endpoint).
3219
+
3220
+ // PR5: kj.config.yml editor modal
3221
+ //
3222
+ // Goal: a non-technical user opens the modal, picks "Sonnet siempre"
3223
+ // from a dropdown, hits "Guardar", and the next `kj run` is cheap.
3224
+ // No yml editing, no terminal trip.
3225
+ //
3226
+ // Each EDITABLE_FIELDS entry the backend exposes becomes one form
3227
+ // row. `select` → <select>, `boolean` → <input type=checkbox>,
3228
+ // `number` → <input type=number min/max>. Help text under each
3229
+ // label as a small grey paragraph.
3230
+ async function showConfigEditor() {
3231
+ let cfg;
3232
+ try {
3233
+ const r = await fetch('/api/config');
3234
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
3235
+ cfg = await r.json();
3236
+ } catch (err) {
3237
+ await showError(`No se pudo leer la configuración: ${err.message}`, { title: 'Configuración' });
3238
+ return;
3239
+ }
3240
+ const dlg = document.getElementById('app-dialog') || ensureDialog();
3241
+ const renderField = (f) => {
3242
+ const id = `cfg-field-${f.key}`;
3243
+ let input = '';
3244
+ if (f.type === 'select') {
3245
+ input = `<select id="${id}" data-key="${esc(f.key)}" style="width:100%;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">
3246
+ ${f.options.map((o) => `<option value="${esc(o)}"${o === f.value ? ' selected' : ''}>${esc(o)}</option>`).join('')}
3247
+ </select>`;
3248
+ } else if (f.type === 'boolean') {
3249
+ input = `<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
3250
+ <input type="checkbox" id="${id}" data-key="${esc(f.key)}"${f.value ? ' checked' : ''} style="cursor:pointer;">
3251
+ <span style="font-size:0.85rem;color:var(--text-muted);">${f.value ? 'activado' : 'desactivado'}</span>
3252
+ </label>`;
3253
+ } else if (f.type === 'number') {
3254
+ input = `<input type="number" id="${id}" data-key="${esc(f.key)}" value="${esc(String(f.value))}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''} style="width:120px;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">`;
3255
+ }
3256
+ return `
3257
+ <div style="margin-bottom:14px;">
3258
+ <label for="${id}" style="display:block;font-weight:600;color:var(--text);font-size:0.88rem;margin-bottom:4px;">${esc(f.label)}</label>
3259
+ ${input}
3260
+ ${f.help ? `<p style="margin:4px 0 0;font-size:0.75rem;color:var(--text-muted);">${esc(f.help)}</p>` : ''}
3261
+ </div>
3262
+ `;
3263
+ };
3264
+ dlg.innerHTML = `
3265
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
3266
+ <span style="font-size:1.2rem;">⚙</span>
3267
+ <span>Configuración de Karajan</span>
3268
+ <span style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:0.75rem;color:var(--text-muted);">${esc(cfg.path || '')}</span>
3269
+ </div>
3270
+ <div style="padding:14px 18px;max-height:65vh;overflow:auto;">
3271
+ ${cfg.fields.map(renderField).join('')}
3272
+ <p style="margin:14px 0 0;font-size:0.75rem;color:var(--text-muted);">
3273
+ Los cambios se guardan en <code>kj.config.yml</code> de forma atómica (con copia de seguridad <code>.bak</code>).
3274
+ El próximo <code>kj run</code> los aplicará — no hace falta reiniciar el board.
3275
+ </p>
3276
+ </div>
3277
+ <div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
3278
+ <button id="config-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
3279
+ <button id="config-save" style="padding:8px 16px;background:var(--color-green);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Guardar</button>
3280
+ </div>
3281
+ `;
3282
+ if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
3283
+ dlg.querySelector('#config-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
3284
+ dlg.querySelector('#config-save')?.addEventListener('click', async () => {
3285
+ // Build the patch from the form, comparing against initial values
3286
+ // so we only send what changed.
3287
+ const patch = {};
3288
+ for (const f of cfg.fields) {
3289
+ const el = dlg.querySelector(`[data-key="${f.key}"]`);
3290
+ if (!el) continue;
3291
+ let v;
3292
+ if (f.type === 'boolean') v = el.checked;
3293
+ else if (f.type === 'number') v = Number(el.value);
3294
+ else v = el.value;
3295
+ if (v !== f.value) patch[f.key] = v;
3296
+ }
3297
+ if (Object.keys(patch).length === 0) {
3298
+ try { dlg.close(); } catch { /* ignore */ }
3299
+ return;
3300
+ }
3301
+ try {
3302
+ const r = await fetch('/api/config', {
3303
+ method: 'PUT',
3304
+ headers: { 'Content-Type': 'application/json' },
3305
+ body: JSON.stringify({ patch }),
3306
+ });
3307
+ if (!r.ok) {
3308
+ const body = await r.json().catch(() => ({}));
3309
+ const msg = body?.details?.errors?.length
3310
+ ? body.details.errors.join('\n')
3311
+ : (body.error || `HTTP ${r.status}`);
3312
+ await showError(msg, { title: 'No se pudo guardar' });
3313
+ return;
3314
+ }
3315
+ try { dlg.close(); } catch { /* ignore */ }
3316
+ } catch (err) {
3317
+ await showError(err.message || String(err), { title: 'Error guardando configuración' });
3318
+ }
3319
+ }, { once: true });
3320
+ }
3321
+
3322
+ async function showCommandLauncher() {
3323
+ const dlg = ensureDialog();
3324
+ // Per-command field schemas — kept in the client because each
3325
+ // command has different inputs and the modal renders accordingly.
3326
+ const SCHEMAS = {
3327
+ plan: {
3328
+ label: '📋 kj plan',
3329
+ help: 'Generate an implementation plan with HUs. Paste the task text OR a file path.',
3330
+ fields: [
3331
+ { name: 'taskFile', label: 'SPEC file path (absolute)', type: 'text', placeholder: '/home/me/project/SPEC.md' },
3332
+ { name: 'task', label: 'OR paste task text directly', type: 'textarea', rows: 6 },
3333
+ { name: 'context', label: 'Extra context (optional)', type: 'text' },
3334
+ { name: 'projectDir', label: 'Project directory (optional, defaults to board\'s cwd)', type: 'text' },
3335
+ { name: 'quick', label: '--quick (skip synth + reviewer)', type: 'checkbox' },
3336
+ ],
3337
+ },
3338
+ architect: {
3339
+ label: '🏛 kj architect',
3340
+ help: 'Design an architecture and persist ADRs. Same input as kj plan.',
3341
+ fields: [
3342
+ { name: 'taskFile', label: 'SPEC file path (absolute)', type: 'text', placeholder: '/home/me/project/SPEC.md' },
3343
+ { name: 'task', label: 'OR paste task text directly', type: 'textarea', rows: 6 },
3344
+ { name: 'context', label: 'Extra context (optional)', type: 'text' },
3345
+ { name: 'projectDir', label: 'Project directory (optional)', type: 'text' },
3346
+ ],
3347
+ },
3348
+ clean: {
3349
+ label: '🧹 kj clean',
3350
+ help: 'Garbage-collect plans / sessions / batches. --nuke wipes everything including the board DB.',
3351
+ fields: [
3352
+ { name: 'dryRun', label: '--dry-run (just report)', type: 'checkbox' },
3353
+ { name: 'nuke', label: '⚠ --nuke (delete everything, including this board\'s DB)', type: 'checkbox' },
3354
+ ],
3355
+ },
3356
+ };
3357
+
3358
+ let active = 'plan';
3359
+ const renderForm = () => {
3360
+ const s = SCHEMAS[active];
3361
+ const fields = s.fields.map((f) => {
3362
+ const id = `cmd-field-${f.name}`;
3363
+ if (f.type === 'textarea') {
3364
+ return `
3365
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
3366
+ <span style="color:var(--text-muted)">${esc(f.label)}</span>
3367
+ <textarea id="${id}" rows="${f.rows || 4}" placeholder="${esc(f.placeholder || '')}"
3368
+ 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.45;resize:vertical"></textarea>
3369
+ </label>`;
3370
+ }
3371
+ if (f.type === 'checkbox') {
3372
+ return `
3373
+ <label style="display:flex;align-items:center;gap:8px;font-size:0.85rem">
3374
+ <input type="checkbox" id="${id}">
3375
+ <span>${esc(f.label)}</span>
3376
+ </label>`;
3377
+ }
3378
+ return `
3379
+ <label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
3380
+ <span style="color:var(--text-muted)">${esc(f.label)}</span>
3381
+ <input type="text" id="${id}" placeholder="${esc(f.placeholder || '')}"
3382
+ style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
3383
+ </label>`;
3384
+ }).join('');
3385
+
3386
+ dlg.innerHTML = `
3387
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600;display:flex;align-items:center;justify-content:space-between;gap:10px">
3388
+ <span>⚡ Run a Karajan command</span>
3389
+ <button id="cmd-close" type="button" class="control-btn"
3390
+ style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
3391
+ ✕
3392
+ </button>
3393
+ </div>
3394
+ <div style="padding:12px 18px;display:flex;gap:8px;border-bottom:1px solid var(--border)">
3395
+ ${Object.entries(SCHEMAS).map(([key, s]) => `
3396
+ <button type="button" data-cmd="${key}"
3397
+ style="padding:6px 12px;border:1px solid var(--border);
3398
+ background:${key === active ? 'var(--color-green)' : 'var(--bg-primary)'};
3399
+ color:${key === active ? '#fff' : 'var(--text)'};
3400
+ border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem;font-weight:${key === active ? '600' : '400'}">
3401
+ ${esc(s.label)}
3402
+ </button>
3403
+ `).join('')}
3404
+ </div>
3405
+ <form id="cmd-form" onsubmit="return false"
3406
+ style="padding:14px 18px;display:flex;flex-direction:column;gap:12px;width:min(640px, 88vw);box-sizing:border-box">
3407
+ <div style="font-size:0.8rem;color:var(--text-muted)">${esc(SCHEMAS[active].help)}</div>
3408
+ ${fields}
3409
+ <div style="display:flex;justify-content:flex-end;gap:8px;padding-top:8px;border-top:1px solid var(--border)">
3410
+ <button type="button" id="cmd-cancel" class="control-btn"
3411
+ style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
3412
+ Cancel
3413
+ </button>
3414
+ <button type="button" id="cmd-submit" class="control-btn"
3415
+ style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
3416
+ Run
3417
+ </button>
3418
+ </div>
3419
+ </form>
3420
+ `;
3421
+
3422
+ dlg.querySelectorAll('[data-cmd]').forEach((btn) => {
3423
+ btn.addEventListener('click', () => { active = btn.dataset.cmd; renderForm(); });
3424
+ });
3425
+ dlg.querySelector('#cmd-close').addEventListener('click', () => dlg.close());
3426
+ dlg.querySelector('#cmd-cancel').addEventListener('click', () => dlg.close());
3427
+ dlg.querySelector('#cmd-submit').addEventListener('click', () => submitCommand(active));
3428
+ };
3429
+
3430
+ async function submitCommand(command) {
3431
+ const s = SCHEMAS[command];
3432
+ const body = {};
3433
+ for (const f of s.fields) {
3434
+ const el = document.getElementById(`cmd-field-${f.name}`);
3435
+ if (!el) continue;
3436
+ if (f.type === 'checkbox') body[f.name] = el.checked;
3437
+ else {
3438
+ const v = (el.value || '').trim();
3439
+ if (v) body[f.name] = v;
3440
+ }
3441
+ }
3442
+ // plan / architect: at least one of taskFile or task is required.
3443
+ if ((command === 'plan' || command === 'architect') && !body.taskFile && !body.task) {
3444
+ await showError('Provide either a SPEC file path or paste the task text.', { title: 'Missing input' });
3445
+ return;
3446
+ }
3447
+ if (command === 'clean' && body.nuke) {
3448
+ const ok = await showConfirm(
3449
+ '`kj clean --nuke` will delete EVERY plan / session / batch + wipe the HU Board DB. Cannot be undone.',
3450
+ { title: 'Confirm --nuke', okLabel: 'Wipe everything', destructive: true }
3451
+ );
3452
+ if (!ok) return;
3453
+ }
3454
+
3455
+ let res, payload;
3456
+ try {
3457
+ res = await fetch(`/api/commands/${encodeURIComponent(command)}`, {
3458
+ method: 'POST',
3459
+ headers: { 'Content-Type': 'application/json' },
3460
+ body: JSON.stringify(body),
3461
+ });
3462
+ payload = await res.json().catch(() => ({}));
3463
+ } catch (err) {
3464
+ await showError(err.message, { title: 'Could not launch command' });
3465
+ return;
3466
+ }
3467
+ if (!res.ok) {
3468
+ await showError(payload.error || `HTTP ${res.status}`, { title: 'Command rejected' });
3469
+ return;
3470
+ }
3471
+
3472
+ dlg.close();
3473
+ if (payload.commandId) openCommandLogViewer(command, payload.commandId);
3474
+ }
3475
+
3476
+ renderForm();
3477
+ dlg.showModal();
3478
+ }
3479
+
3480
+ /**
3481
+ * Open the run-log panel pointed at a /api/runs/<commandId>/log
3482
+ * tail. Reuses the same panel UI as openLogViewer() for plans —
3483
+ * just swaps the URL it polls.
3484
+ */
3485
+ function openCommandLogViewer(commandLabel, commandId) {
3486
+ const args = {
3487
+ id: commandId,
3488
+ label: `kj ${commandLabel}`,
3489
+ tailUrl: (offset) => `/api/runs/${encodeURIComponent(commandId)}/log?offset=${offset}`,
3490
+ };
3491
+ // Remember it so the section header's 📜 View log button can
3492
+ // re-open this same log after the user closes the panel.
3493
+ lastOpenedLog = args;
3494
+ openGenericLogPanel(args);
3495
+ }
3496
+
3497
+ // ---- Initialization ----
3498
+
3499
+ // Nav button clicks
3500
+ document.querySelectorAll('.nav-btn').forEach((btn) => {
3501
+ btn.addEventListener('click', () => navigate(btn.dataset.view));
3502
+ });
3503
+
3504
+ // Project selector
3505
+ document.getElementById('project-select').addEventListener('change', (e) => {
3506
+ selectedProject = e.target.value;
3507
+ window.location.hash = selectedProject ? `${currentView}/${selectedProject}` : currentView;
3508
+ render();
3509
+ });
3510
+
3511
+ // Sync button — re-scan disk for new batches
3512
+ document.getElementById('sync-btn').addEventListener('click', async () => {
3513
+ const btn = document.getElementById('sync-btn');
3514
+ btn.disabled = true;
3515
+ btn.textContent = '⏳';
3516
+ try {
3517
+ await fetch('/api/sync', { method: 'POST' });
3518
+ await populateProjectSelect();
3519
+ render();
3520
+ } catch { /* ignore */ }
3521
+ btn.textContent = '🔄';
3522
+ btn.disabled = false;
3523
+ });
3524
+
3525
+ // Commands button — opens the launcher modal so the user can run
3526
+ // `kj plan`, `kj architect`, `kj clean` (and more later) without
3527
+ // dropping to a terminal. Output streams to the existing log panel.
3528
+ document.getElementById('commands-btn').addEventListener('click', () => {
3529
+ showCommandLauncher();
3530
+ });
3531
+
3532
+ // Help button — KJC-TSK-0372. Quick reference modal for the 5 views.
3533
+ // Hover tooltips on each tab cover the 1-line case; this modal is
3534
+ // for users who arrive cold and want "ok, what does each one do?".
3535
+ document.getElementById('help-btn').addEventListener('click', () => {
3536
+ showHelp();
3537
+ });
3538
+
3539
+ // PR5: Settings button — opens the kj.config.yml editor modal.
3540
+ // User picks coder/reviewer providers, model strategy, iteration
3541
+ // caps, etc. in plain Spanish. Save writes the yml atomically;
3542
+ // next `kj run` picks up the new config (no restart needed).
3543
+ document.getElementById('config-btn').addEventListener('click', () => {
3544
+ showConfigEditor();
3545
+ });
3546
+
3547
+ // Restart button — respawn the board server in place. The page's
3548
+ // EventSource auto-reconnects so the user sees a brief "connecting…"
3549
+ // blip and the board comes back live.
3550
+ document.getElementById('restart-btn').addEventListener('click', async () => {
3551
+ const ok = await showConfirm(
3552
+ 'Restart the HU Board server now? The page will reconnect automatically.',
3553
+ { title: 'Restart board', okLabel: 'Restart' }
3554
+ );
3555
+ if (!ok) return;
3556
+ try {
3557
+ await fetch('/api/board/restart', { method: 'POST' });
3558
+ } catch { /* the server is going down; the catch is expected */ }
3559
+ // Give the new server a beat to bind, then reload the page.
3560
+ setTimeout(() => window.location.reload(), 1200);
3561
+ });
3562
+
3563
+ // is_test toggle (KJC-TSK-0371 — board polish #3). Three-state cycle:
3564
+ // null (heuristic) → 1 (always test) → 0 (always keep) → null …
3565
+ // `null` is encoded as the empty string in `data-is-test` so the
3566
+ // button's HTML serialisation is stable across renders.
3567
+ function nextIsTestValue(current) {
3568
+ // Treat both empty string ("") and "null" as "no override" — the
3569
+ // dataset value is the empty string at render time, but URLs and
3570
+ // some legacy callers may send the literal "null".
3571
+ if (current === '' || current === 'null') return 1;
3572
+ if (current === '1') return 0;
3573
+ return null; // from "0" → revert to default
3574
+ }
3575
+
3576
+ document.addEventListener('click', async (e) => {
3577
+ const btn = e.target.closest('.project-card__is-test');
3578
+ if (!btn) return;
3579
+ e.stopPropagation();
3580
+ e.preventDefault();
3581
+ const projectId = btn.dataset.projectId;
3582
+ const next = nextIsTestValue(btn.dataset.isTest);
3583
+ try {
3584
+ const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/is-test`, {
3585
+ method: 'PATCH',
3586
+ headers: { 'Content-Type': 'application/json' },
3587
+ body: JSON.stringify({ is_test: next }),
3588
+ });
3589
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3590
+ // Re-render the project list so the badge reflects the new value.
3591
+ await populateProjectSelect();
3592
+ render();
3593
+ } catch (err) {
3594
+ await showError(err.message, { title: 'Failed to update is_test' });
3595
+ }
3596
+ });
3597
+
3598
+ // Delete project (cascade) — delegated handler on the dashboard grid
3599
+ document.addEventListener('click', async (e) => {
3600
+ const btn = e.target.closest('.project-card__delete');
3601
+ if (!btn) return;
3602
+ e.stopPropagation();
3603
+ e.preventDefault();
3604
+ const projectId = btn.dataset.projectId;
3605
+ const projectName = btn.dataset.projectName || projectId;
3606
+ const ok = await showConfirm(
3607
+ `Delete project "${projectName}" and all its stories + sessions?\n\n`
3608
+ + `Also removes ~/.karajan/hu-stories/${projectId}/ from disk.`,
3609
+ { title: 'Delete project', okLabel: 'Delete', destructive: true }
3610
+ );
3611
+ if (!ok) return;
3612
+ try {
3613
+ const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
3614
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3615
+ await populateProjectSelect();
3616
+ render();
3617
+ } catch (err) {
3618
+ await showError(err.message, { title: 'Failed to delete project' });
3619
+ }
3620
+ });
3621
+
3622
+ // Modal close on backdrop click
3623
+ document.getElementById('modal-backdrop').addEventListener('click', (e) => {
3624
+ if (e.target === e.currentTarget) closeModal();
3625
+ });
3626
+
3627
+ // ESC to close modal
3628
+ document.addEventListener('keydown', (e) => {
3629
+ if (e.key === 'Escape') closeModal();
3630
+ });
3631
+
3632
+ // Hash routing
3633
+ window.addEventListener('hashchange', handleRoute);
3634
+
3635
+ // Make functions available globally for onclick handlers
3636
+ window.showStoryDetail = showStoryDetail;
3637
+ window.showSessionDetail = showSessionDetail;
3638
+ window.closeModal = closeModal;
3639
+ window.selectProject = selectProject;
3640
+
3641
+ // KJC-TSK-0414 PR4: arranca el polling del banner de standby al cargar la página.
3642
+ startStandbyPolling();
3643
+
3644
+ // Initial load — sync disk data first so new batches are visible
3645
+ triggerSync().then(() => {
3646
+ populateProjectSelect();
3647
+ handleRoute();
3648
+ subscribeToServerEvents();
3649
+ });
3650
+
3651
+ // ---- Server-push updates ----
3652
+ //
3653
+ // The board gets a one-way stream of events from the server via
3654
+ // Server-Sent Events; each event hints at what changed (plan file,
3655
+ // session, batch). On receipt we refresh the current view in a way
3656
+ // that preserves scroll position and any open modal. Full-page
3657
+ // re-render is no longer on the table — clicking around doesn't
3658
+ // reset your scroll anymore.
3659
+ //
3660
+ // EventSource auto-reconnects on drop, so we don't manage lifecycle
3661
+ // ourselves. The status line below the header flashes briefly on
3662
+ // each update so the user can see the data is live.
3663
+
3664
+ let sseSource = null;
3665
+ let sseRefreshTimer = null;
3666
+
3667
+ function subscribeToServerEvents() {
3668
+ if (typeof EventSource === 'undefined') return; // ancient browser
3669
+ if (sseSource) sseSource.close();
3670
+ sseSource = new EventSource('/api/events');
3671
+ sseSource.addEventListener('message', (e) => {
3672
+ // Try to parse the event as a typed payload. Prompt events drive
3673
+ // the prompt modal; everything else just nudges a re-render.
3674
+ let payload;
3675
+ try { payload = JSON.parse(e.data); } catch { payload = null; }
3676
+
3677
+ if (payload?.type === 'prompt' && payload.promptId) {
3678
+ // A non-interactive runner is asking for input — pop the modal
3679
+ // immediately. fetch the full prompt body from the API so we
3680
+ // don't depend on the SSE event carrying every field.
3681
+ fetch(`/api/prompts`).then(r => r.ok ? r.json() : []).then((list) => {
3682
+ const match = list.find((p) => p.promptId === payload.promptId);
3683
+ if (match) showPromptModal(match);
3684
+ }).catch(() => {});
3685
+ return;
3686
+ }
3687
+ if (payload?.type === 'prompt-resolved' && payload.promptId) {
3688
+ // Runner read its answer file — close the modal if it's still open.
3689
+ closePromptModalIfMatches(payload.promptId);
3690
+ return;
3691
+ }
3692
+
3693
+ // Default: data changed somewhere. Try the targeted DOM patch
3694
+ // first (no parpadeo, preserves scroll + hover state) and only
3695
+ // fall back to a full re-render when the patch can't apply
3696
+ // (different view, missing markers, etc.). This is the fix for
3697
+ // the "todo el board parpadea cada vez que algo cambia" UX bug.
3698
+ if (sseRefreshTimer) clearTimeout(sseRefreshTimer);
3699
+ sseRefreshTimer = setTimeout(() => smartRefresh(payload), 150);
3700
+ });
3701
+ sseSource.addEventListener('error', () => {
3702
+ // EventSource handles reconnection. Nothing to do; browsers retry
3703
+ // the `retry:` value we sent on open.
3704
+ });
3705
+
3706
+ // On boot, also fetch any prompts that were already pending before
3707
+ // this tab connected (we missed their SSE add-event).
3708
+ fetch('/api/prompts').then((r) => r.ok ? r.json() : []).then((list) => {
3709
+ for (const p of list) showPromptModal(p);
3710
+ }).catch(() => {});
3711
+ }
3712
+
3713
+ // ---- Prompt modal ----
3714
+ //
3715
+ // The runner publishes a prompt JSON file when it needs an answer
3716
+ // (Solomon escalations, max-iterations decisions, anything wired to
3717
+ // askQuestion) and there's no TTY to ask through. The board surfaces
3718
+ // the prompt as a modal dialog with a text input. When the user
3719
+ // answers, we POST back; the runner sees the answer file and
3720
+ // resolves.
3721
+
3722
+ let activePromptId = null;
3723
+
3724
+ function showPromptModal(prompt) {
3725
+ if (!prompt?.promptId) return;
3726
+ if (activePromptId === prompt.promptId) return;
3727
+ activePromptId = prompt.promptId;
3728
+ const dlg = ensureDialog();
3729
+ const ctxBlock = prompt.context
3730
+ ? `<details style="margin-top:8px">
3731
+ <summary style="cursor:pointer;color:var(--text-muted);font-size:0.85rem">Context</summary>
3732
+ <pre style="white-space:pre-wrap;font-size:0.78rem;background:var(--bg-primary);padding:8px;border-radius:var(--radius-sm);overflow:auto;max-height:240px">${esc(JSON.stringify(prompt.context, null, 2))}</pre>
3733
+ </details>`
3734
+ : '';
3735
+ dlg.innerHTML = `
3736
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600;display:flex;align-items:center;gap:10px">
3737
+ <span>❓ Karajan needs an answer</span>
3738
+ ${prompt.sessionId ? `<span style="font-family:var(--font-mono, monospace);font-size:0.75rem;color:var(--text-muted)">${esc(prompt.sessionId)}</span>` : ''}
3739
+ </div>
3740
+ <div style="padding:14px 18px">
3741
+ <div style="white-space:pre-wrap;font-size:0.95rem;line-height:1.55">${esc(prompt.question)}</div>
3742
+ ${ctxBlock}
3743
+ <textarea id="prompt-answer" rows="3"
3744
+ placeholder="Type your answer (or 'stop' to abort the run)"
3745
+ style="margin-top:12px;width:100%;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;box-sizing:border-box"></textarea>
3746
+ </div>
3747
+ <div style="display:flex;justify-content:space-between;align-items:center;padding:10px 18px;border-top:1px solid var(--border);gap:8px">
3748
+ <span style="font-size:0.75rem;color:var(--text-muted)">
3749
+ The runner is blocked waiting for this answer. Closing the dialog without answering aborts the prompt (run stops).
3750
+ </span>
3751
+ <div style="display:flex;gap:8px">
3752
+ <button id="prompt-stop" type="button" class="control-btn"
3753
+ style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
3754
+ Stop
3755
+ </button>
3756
+ <button id="prompt-send" type="button" class="control-btn"
3757
+ style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
3758
+ Send
3759
+ </button>
3760
+ </div>
3761
+ </div>
3762
+ `;
3763
+
3764
+ const send = async (rawAnswer) => {
3765
+ const answer = String(rawAnswer ?? '');
3766
+ try {
3767
+ await fetch(`/api/prompts/${encodeURIComponent(prompt.promptId)}/answer`, {
3768
+ method: 'POST',
3769
+ headers: { 'Content-Type': 'application/json' },
3770
+ body: JSON.stringify({ answer }),
3771
+ });
3772
+ } catch (err) {
3773
+ await showError(`Could not deliver answer: ${err.message}`, { title: 'Prompt delivery failed' });
3774
+ return;
3775
+ }
3776
+ activePromptId = null;
3777
+ if (dlg.open) dlg.close();
3778
+ };
3779
+
3780
+ dlg.querySelector('#prompt-send').addEventListener('click', () => {
3781
+ send(dlg.querySelector('#prompt-answer').value.trim());
3782
+ });
3783
+ dlg.querySelector('#prompt-stop').addEventListener('click', () => send('stop'));
3784
+
3785
+ // Esc / backdrop click also count as Stop so the user can't accidentally
3786
+ // leave the runner blocked.
3787
+ dlg.addEventListener('close', () => {
3788
+ if (activePromptId === prompt.promptId) {
3789
+ activePromptId = null;
3790
+ // Best-effort stop; ignore errors if the runner already moved on.
3791
+ fetch(`/api/prompts/${encodeURIComponent(prompt.promptId)}/answer`, {
3792
+ method: 'POST',
3793
+ headers: { 'Content-Type': 'application/json' },
3794
+ body: JSON.stringify({ answer: 'stop' }),
3795
+ }).catch(() => {});
3796
+ }
3797
+ }, { once: true });
3798
+
3799
+ dlg.showModal();
3800
+ setTimeout(() => dlg.querySelector('#prompt-answer')?.focus(), 50);
3801
+ }
3802
+
3803
+ function closePromptModalIfMatches(promptId) {
3804
+ if (activePromptId !== promptId) return;
3805
+ activePromptId = null;
3806
+ const dlg = document.getElementById('app-dialog');
3807
+ if (dlg && dlg.open) dlg.close();
3808
+ }
3809
+
3810
+ /**
3811
+ * Refresh the current SPA view without touching scroll position or
3812
+ * any modal state. The heavy lifting already lives in renderBoard /
3813
+ * renderGraph / renderSessions; this just re-calls them while the
3814
+ * scroll offset is pinned, so the visual effect is "the card
3815
+ * status updated in place".
3816
+ * @param {string} _rawEvent unused for now — we could branch on
3817
+ * event type later to patch only the touched card, but a full
3818
+ * re-render with scroll preservation is already invisible to
3819
+ * the user and a lot simpler to reason about.
3820
+ */
3821
+ async function refreshCurrentView(_rawEvent) {
3822
+ const app = document.getElementById('app');
3823
+ const scrollTop = app?.scrollTop ?? 0;
3824
+ const scrollLeft = app?.scrollLeft ?? 0;
3825
+ // Modal is in a separate DOM branch (#modal-backdrop) so render()
3826
+ // never touches it — we don't need to save its state.
3827
+ try {
3828
+ await render();
3829
+ } finally {
3830
+ const fresh = document.getElementById('app');
3831
+ if (fresh) {
3832
+ fresh.scrollTop = scrollTop;
3833
+ fresh.scrollLeft = scrollLeft;
3834
+ }
3835
+ }
3836
+ }
3837
+
3838
+ /**
3839
+ * Smart refresh: pick the cheapest possible DOM update for the
3840
+ * incoming SSE event. Today the server sends coarse-grained events
3841
+ * (`{type: 'plan'|'batch'|'session'|'prompt'|...}`), so we route by
3842
+ * type:
3843
+ *
3844
+ * plan / batch on the board view → patchBoardIncremental()
3845
+ * anything else → refreshCurrentView() (full re-render)
3846
+ *
3847
+ * The patch path preserves DOM nodes (no innerHTML rewrite, no
3848
+ * scroll reset, no hover/focus loss) — this is what kills the
3849
+ * "parpadeo" the user reported.
3850
+ *
3851
+ * @param {object|null} payload The parsed SSE payload, or null when
3852
+ * the event wasn't valid JSON. Coarse types still work (we just
3853
+ * degrade to a full re-render).
3854
+ */
3855
+ async function smartRefresh(payload) {
3856
+ const onBoard = currentView === 'board' && Boolean(selectedProject);
3857
+ const isStoryRelated = payload && ['plan', 'batch'].includes(payload.type);
3858
+ if (onBoard && isStoryRelated) {
3859
+ const ok = await patchBoardIncremental();
3860
+ if (ok) return;
3861
+ }
3862
+ await refreshCurrentView();
3863
+ }
3864
+
3865
+ /**
3866
+ * Targeted DOM patch for the Kanban board. Re-fetches the project's
3867
+ * stories, diffs them against the cards already rendered, and only
3868
+ * touches the affected nodes:
3869
+ *
3870
+ * - status changed → move the card to the new column with a
3871
+ * subtle animation (no DOM recreation, listeners survive)
3872
+ * - new story → render + insert into the right column
3873
+ * - story removed → fade + remove
3874
+ * - column counts and the running badge are updated in place
3875
+ *
3876
+ * Returns false if the board's DOM markers aren't present (different
3877
+ * view, error state, etc.) so smartRefresh can fall back to a full
3878
+ * re-render.
3879
+ *
3880
+ * @returns {Promise<boolean>}
3881
+ */
3882
+ async function patchBoardIncremental() {
3883
+ const kanban = document.querySelector('.kanban');
3884
+ if (!kanban) return false; // not on the board view
3885
+ const columns = {};
3886
+ for (const col of kanban.querySelectorAll('.kanban__column')) {
3887
+ const cls = col.dataset.column;
3888
+ if (cls) columns[cls] = col;
3889
+ }
3890
+ if (!columns.pending || !columns.running) return false;
3891
+
3892
+ let stories;
3893
+ try {
3894
+ stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
3895
+ } catch { return false; }
3896
+
3897
+ // Re-derive the column → status mapping (kept in lockstep with
3898
+ // renderBoard's `columns` object — change there, change here).
3899
+ const statusToColumn = (status) => {
3900
+ if (['pending', 'certified', 'needs_context', 'blocked'].includes(status)) return 'pending';
3901
+ if (['coding', 'reviewing'].includes(status)) return 'running';
3902
+ if (status === 'done') return 'done';
3903
+ if (status === 'failed') return 'failed';
3904
+ return 'pending'; // safe default
3905
+ };
3906
+
3907
+ const seen = new Set();
3908
+ for (const story of stories) {
3909
+ seen.add(story.id);
3910
+ const targetCol = columns[statusToColumn(story.status)];
3911
+ if (!targetCol) continue;
3912
+ const existing = kanban.querySelector(`.story-card[data-story-id="${cssEscape(story.id)}"]`);
3913
+ if (!existing) {
3914
+ // New story — append to its column.
3915
+ const wrapper = document.createElement('div');
3916
+ wrapper.innerHTML = renderStoryCard(story).trim();
3917
+ const node = wrapper.firstElementChild;
3918
+ if (node) {
3919
+ node.style.opacity = '0';
3920
+ targetCol.appendChild(node);
3921
+ requestAnimationFrame(() => { node.style.transition = 'opacity 200ms'; node.style.opacity = '1'; });
3922
+ }
3923
+ continue;
3924
+ }
3925
+ const previousStatus = existing.dataset.status;
3926
+ if (previousStatus !== story.status) {
3927
+ // Status changed — move the node + replace its inner content
3928
+ // (counters, time-ago, status pill) without recreating the
3929
+ // outer node so click handlers + transitions survive.
3930
+ const wrapper = document.createElement('div');
3931
+ wrapper.innerHTML = renderStoryCard(story).trim();
3932
+ const fresh = wrapper.firstElementChild;
3933
+ if (fresh) {
3934
+ existing.innerHTML = fresh.innerHTML;
3935
+ existing.dataset.status = story.status;
3936
+ existing.setAttribute('onclick', fresh.getAttribute('onclick') || '');
3937
+ if (existing.parentElement !== targetCol) {
3938
+ existing.style.transition = 'opacity 150ms';
3939
+ existing.style.opacity = '0.3';
3940
+ setTimeout(() => {
3941
+ targetCol.appendChild(existing);
3942
+ requestAnimationFrame(() => { existing.style.opacity = '1'; });
3943
+ }, 150);
3944
+ }
3945
+ }
3946
+ } else {
3947
+ // Same status — just refresh the inner block (counters / time
3948
+ // ago / antipatterns may have changed) without moving.
3949
+ const wrapper = document.createElement('div');
3950
+ wrapper.innerHTML = renderStoryCard(story).trim();
3951
+ const fresh = wrapper.firstElementChild;
3952
+ if (fresh && fresh.innerHTML !== existing.innerHTML) {
3953
+ existing.innerHTML = fresh.innerHTML;
3954
+ }
3955
+ }
3956
+ }
3957
+ // Cards that no longer exist on the server → remove with a fade.
3958
+ for (const node of kanban.querySelectorAll('.story-card')) {
3959
+ if (!seen.has(node.dataset.storyId)) {
3960
+ node.style.transition = 'opacity 150ms';
3961
+ node.style.opacity = '0';
3962
+ setTimeout(() => node.remove(), 160);
3963
+ }
3964
+ }
3965
+ // Recount each column + update the "N running" badge in the header.
3966
+ for (const [cls, col] of Object.entries(columns)) {
3967
+ const counter = col.querySelector('[data-column-count]');
3968
+ if (counter) counter.textContent = String(col.querySelectorAll('.story-card').length);
3969
+ col.style.opacity = col.querySelectorAll('.story-card').length === 0 ? '0.55' : '1';
3970
+ }
3971
+ const runningCount = columns.running.querySelectorAll('.story-card').length;
3972
+ const runningBadge = document.querySelector('.section-header__badge');
3973
+ if (runningBadge) {
3974
+ if (runningCount > 0) {
3975
+ runningBadge.textContent = `⚙ ${runningCount} running…`;
3976
+ runningBadge.style.display = '';
3977
+ } else {
3978
+ runningBadge.style.display = 'none';
3979
+ }
3980
+ }
3981
+ return true;
3982
+ }
3983
+
3984
+ /**
3985
+ * CSS.escape polyfill wrapper. Story IDs can contain "::" and other
3986
+ * characters that break querySelector when interpolated raw.
3987
+ */
3988
+ function cssEscape(str) {
3989
+ return (window.CSS && CSS.escape) ? CSS.escape(str) : String(str).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
3990
+ }
3991
+
3992
+ // Auto-refresh every 60 seconds as a SAFETY NET only — the board is
3993
+ // SSE-driven (push from server) so this should rarely fire. We keep
3994
+ // it at low frequency to recover from missed events on hibernation /
3995
+ // network blip without flickering the UI every 10s like before.
3996
+ // Uses smartRefresh too so it patches the DOM instead of full rerender.
3997
+ refreshInterval = setInterval(async () => {
3998
+ if (document.getElementById('modal-backdrop').classList.contains('hidden')) {
3999
+ await triggerSync();
4000
+ await populateProjectSelect();
4001
+ await smartRefresh(null);
4002
+ }
4003
+ }, 60_000);
4004
+
4005
+ // --- Server-restart detector (KJC-TSK-0379) ---------------------------
4006
+ // Poll /api/version every 30s. First response anchors the baseline; if
4007
+ // `boot_time` later differs, the server restarted (likely after a `kj
4008
+ // board start`) and the client reloads to pick up any new HTML/JS
4009
+ // served with `Cache-Control: no-store`. Avoids the "I killed the
4010
+ // server but the browser keeps showing stale UI" pain.
4011
+ let __versionBaseline = null;
4012
+ async function pollServerVersion() {
4013
+ try {
4014
+ const res = await api('/version');
4015
+ if (!__versionBaseline) { __versionBaseline = res; return; }
4016
+ if (res.boot_time !== __versionBaseline.boot_time) {
4017
+ console.info('[hu-board] server restart detected, reloading...');
4018
+ window.forceRefresh();
4019
+ }
4020
+ } catch (_) { /* network blip; try next tick */ }
4021
+ }
4022
+ setInterval(pollServerVersion, 30_000);
4023
+ pollServerVersion();
4024
+
4025
+ // Manual escape hatch: bound to the 🔄 button in the header. Wipes all
4026
+ // caches the browser may be holding (Cache API + sessionStorage) and
4027
+ // hard-reloads. Useful when the user thinks something is wrong even
4028
+ // though the version-poll hasn't fired.
4029
+ window.forceRefresh = async function forceRefresh() {
4030
+ try {
4031
+ if ('caches' in window) {
4032
+ const keys = await caches.keys();
4033
+ await Promise.all(keys.map((k) => caches.delete(k)));
4034
+ }
4035
+ sessionStorage.clear();
4036
+ } catch (_) { /* best effort */ }
4037
+ window.location.reload();
4038
+ };