karajan-code 3.0.0 → 3.1.0

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