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
@@ -0,0 +1,139 @@
1
+ // KJC-TSK-0501 step 7/8 — Project picker view (default Board landing).
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so
4
+ // the function declaration hoists into the script-level lexical
5
+ // environment and app.js can call renderProjectPicker() from its
6
+ // router.
7
+ //
8
+ // Globals consumed from earlier scripts / app.js:
9
+ // - api() (utils/api.js)
10
+ // - esc() (app.js)
11
+ // - renderEmptyState() (app.js)
12
+ // - resolveProjectMeta() (app.js)
13
+ // - projectNameCache (mutable, app.js)
14
+ // - humaniseProjectName() (app.js)
15
+
16
+ /**
17
+ * "Pick a project" view shown by the Board tab when nothing is
18
+ * selected. Lists every project that has at least one HU with the
19
+ * pending / running / done / failed counts — clicking takes the
20
+ * user into that project's kanban (`#board/<slug>`).
21
+ *
22
+ * Why this instead of an "All projects" merged kanban: HU short ids
23
+ * collide across projects (every project has a `*-001`), the column
24
+ * counts become meaningless, and "missing test contract" warnings
25
+ * apply to whatever-project so the user can't tell what to act on.
26
+ * Forcing a project pick before the kanban is the cheapest way to
27
+ * keep every other UI invariant honest.
28
+ */
29
+ async function renderProjectPicker() {
30
+ const app = document.getElementById('app');
31
+ const projects = await api('/api/projects');
32
+ // Pre-resolve human names so the rendered list matches what the
33
+ // header / dropdown show elsewhere.
34
+ await Promise.all(projects.map((p) => resolveProjectMeta(p.id)));
35
+
36
+ if (projects.length === 0) {
37
+ app.innerHTML = renderEmptyState(
38
+ 'No projects yet',
39
+ 'Run kj plan or click ⚡ in the header to generate one. The board will pick it up automatically.'
40
+ );
41
+ return;
42
+ }
43
+
44
+ // Per-project status counts come from /api/projects/:id/stories.
45
+ // One round-trip per project; cheap on a local SQLite-backed API.
46
+ const allStories = await Promise.all(
47
+ projects.map((p) => api(`/api/projects/${encodeURIComponent(p.id)}/stories`).then((s) => ({ id: p.id, stories: s })))
48
+ );
49
+ const byProject = new Map(allStories.map((entry) => [entry.id, entry.stories]));
50
+
51
+ function bucket(stories) {
52
+ const c = { pending: 0, running: 0, done: 0, failed: 0 };
53
+ for (const s of stories) {
54
+ if (['pending', 'certified', 'needs_context', 'blocked'].includes(s.status)) c.pending += 1;
55
+ else if (['coding', 'reviewing'].includes(s.status)) c.running += 1;
56
+ else if (s.status === 'done') c.done += 1;
57
+ else if (s.status === 'failed') c.failed += 1;
58
+ }
59
+ return c;
60
+ }
61
+
62
+ // Sort: most recent activity first. Falls back to alphabetic when
63
+ // last_activity is missing (older projects synced pre-#480).
64
+ const sorted = [...projects].sort((a, b) => {
65
+ const la = a.last_activity || '';
66
+ const lb = b.last_activity || '';
67
+ if (la && lb && la !== lb) return lb.localeCompare(la);
68
+ return (a.name || a.id).localeCompare(b.name || b.id);
69
+ });
70
+
71
+ app.innerHTML = `
72
+ <div class="section-header">
73
+ <span class="section-header__title">Story Board</span>
74
+ <span class="section-header__count">${sorted.length} project${sorted.length === 1 ? '' : 's'}</span>
75
+ </div>
76
+ <p style="padding:8px 4px 16px;color:var(--text-muted);font-size:0.9rem">
77
+ Pick a project to see its kanban. Use the project selector in the header to switch later.
78
+ </p>
79
+ <div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:14px">
80
+ ${sorted.map((p) => {
81
+ const counts = bucket(byProject.get(p.id) || []);
82
+ const total = counts.pending + counts.running + counts.done + counts.failed;
83
+ const name = projectNameCache[p.id] || p.name || humaniseProjectName(p.id);
84
+ // div en lugar de <button> para poder anidar el botón de
85
+ // delete (HTML no permite buttons anidados). role+tabindex
86
+ // mantienen la accesibilidad equivalente.
87
+ return `
88
+ <div role="button" tabindex="0" class="project-picker__card" data-project-id="${esc(p.id)}"
89
+ style="position:relative;text-align:left;display:flex;flex-direction:column;gap:8px;padding:14px 16px;
90
+ background:var(--bg-secondary);border:1px solid var(--border);
91
+ border-radius:var(--radius-sm);cursor:pointer;color:var(--text);
92
+ transition:border-color 120ms">
93
+ <button class="project-card__delete" title="Borrar proyecto (cascade)"
94
+ data-project-id="${esc(p.id)}" data-project-name="${esc(name)}">🗑️</button>
95
+ <div style="display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding-right:32px">
96
+ <strong style="font-size:0.95rem">${esc(name)}</strong>
97
+ <span style="font-size:0.75rem;color:var(--text-muted)">${total} HU${total === 1 ? '' : 's'}</span>
98
+ </div>
99
+ <div style="display:flex;flex-wrap:wrap;gap:6px;font-size:0.78rem">
100
+ ${counts.pending > 0 ? `<span title="Pending" style="padding:2px 8px;border-radius:var(--radius-sm);background:var(--bg-primary)">⏳ ${counts.pending}</span>` : ''}
101
+ ${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>` : ''}
102
+ ${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>` : ''}
103
+ ${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>` : ''}
104
+ ${total === 0 ? `<span style="color:var(--text-muted)">empty</span>` : ''}
105
+ </div>
106
+ <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)}">
107
+ ${esc(p.id)}
108
+ </div>
109
+ </div>
110
+ `;
111
+ }).join('')}
112
+ </div>
113
+ `;
114
+
115
+ app.querySelectorAll('.project-picker__card').forEach((btn) => {
116
+ const enter = () => {
117
+ const id = btn.dataset.projectId;
118
+ // Use the existing route so back/forward work and the dropdown
119
+ // syncs via handleRoute().
120
+ window.location.hash = `board/${encodeURIComponent(id)}`;
121
+ };
122
+ btn.addEventListener('click', (e) => {
123
+ // Click sobre el botón delete anidado — el handler global ya
124
+ // hace stopPropagation + preventDefault, pero por defensa
125
+ // ignoramos aquí cualquier click que venga de dentro de él.
126
+ if (e.target.closest('.project-card__delete')) return;
127
+ enter();
128
+ });
129
+ btn.addEventListener('keydown', (e) => {
130
+ if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('.project-card__delete')) {
131
+ e.preventDefault();
132
+ enter();
133
+ }
134
+ });
135
+ // Tiny hover affordance — no CSS file edit needed.
136
+ btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--color-green)'; });
137
+ btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--border)'; });
138
+ });
139
+ }
@@ -0,0 +1,169 @@
1
+ // KJC-TSK-0501 step 8f/8 — Preflight gate + run launcher.
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so the
4
+ // top-level `function`/`let` declarations are hoisted into the shared
5
+ // realm and the inline handlers in app.js (and elsewhere) resolve
6
+ // `runProject` / `confirmRunWithPreflight` via that hoisting.
7
+ //
8
+ // Globals consumed from earlier scripts / app.js:
9
+ // - esc() (utils/formatters.js)
10
+ // - ensureDialog() (app.js)
11
+ // - showError() (utils/modals.js)
12
+ // - renderBoard() (app.js)
13
+ // - openLogViewer() (utils/log-panel.js)
14
+ // - lastLaunchedPlanId (mutable in app.js — assignment crosses
15
+ // scripts transparently)
16
+ // - lastOpenedLog (mutable in app.js — same)
17
+
18
+ // ---- Preflight panel + safety modal ----
19
+ //
20
+ // Goal: when a non-technical user opens a project, they should see at
21
+ // a glance whether the environment is ready (git initialised, remote
22
+ // configured, agents installed, Node version, etc.). Each missing
23
+ // item is rendered with a plain-Spanish "consequence" so the user
24
+ // understands what's at stake.
25
+ //
26
+ // The panel doubles as the gate for ▶ Run plan / ▶ Run HU: if any
27
+ // blocker (status:fail with blocking:true) is present, we refuse to
28
+ // launch. If only warnings are present, we open a confirm modal
29
+ // listing them and require an explicit "Lanzar de todos modos".
30
+
31
+ let preflightCache = null; // memoise per-render so the modal can re-use what the panel already fetched
32
+
33
+ async function fetchPreflight(projectId) {
34
+ try {
35
+ const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/preflight`);
36
+ if (!r.ok) return null;
37
+ return await r.json();
38
+ } catch { return null; }
39
+ }
40
+
41
+ function preflightStatusIcon(status) {
42
+ if (status === 'ok') return '✓';
43
+ if (status === 'warn') return '⚠';
44
+ if (status === 'fail') return '✗';
45
+ return 'ℹ';
46
+ }
47
+ function preflightStatusColor(status) {
48
+ if (status === 'ok') return 'var(--color-green)';
49
+ if (status === 'warn') return 'var(--color-yellow,#eab308)';
50
+ if (status === 'fail') return 'var(--color-red,#ef4444)';
51
+ return 'var(--text-muted)';
52
+ }
53
+
54
+ /**
55
+ * Pre-run safety modal. Returns true if the user wants to proceed,
56
+ * false to cancel. Call BEFORE invoking the run endpoint.
57
+ *
58
+ * Behaviour:
59
+ * - All green → returns true immediately (no modal).
60
+ * - Blockers present → modal explains them in plain Spanish; only
61
+ * "Cancelar" button (no proceed-anyway) because blockers are
62
+ * known to break the run.
63
+ * - Only warnings → modal lists them with their consequences and
64
+ * offers "Cancelar" or "Lanzar de todos modos" (red button so
65
+ * the user notices they're accepting risk).
66
+ */
67
+ async function confirmRunWithPreflight(projectId) {
68
+ const data = preflightCache || await fetchPreflight(projectId);
69
+ if (!data) return true; // can't fetch → don't block the user
70
+ const blockers = data.blockers || [];
71
+ const warnings = data.warnings || [];
72
+ if (blockers.length === 0 && warnings.length === 0) return true;
73
+
74
+ return await new Promise((resolve) => {
75
+ const dlg = document.getElementById('app-dialog') || ensureDialog();
76
+ const isBlocked = blockers.length > 0;
77
+ const headerColor = isBlocked ? 'var(--color-red,#ef4444)' : 'var(--color-yellow,#eab308)';
78
+ const allItems = [...blockers, ...warnings];
79
+ dlg.innerHTML = `
80
+ <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;">
81
+ <span style="font-size:1.2rem;">${isBlocked ? '✗' : '⚠'}</span>
82
+ <span>${isBlocked ? 'No se puede lanzar — falta algo crítico' : 'Atención antes de lanzar'}</span>
83
+ </div>
84
+ <div style="padding:14px 18px;max-height:60vh;overflow:auto;">
85
+ <p style="margin:0 0 10px;font-size:0.9rem;color:var(--text);">
86
+ ${isBlocked
87
+ ? 'Se detectaron problemas bloqueantes que impedirán que Karajan funcione correctamente:'
88
+ : 'Se detectaron avisos. Karajan puede ejecutarse, pero con limitaciones:'}
89
+ </p>
90
+ <ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px;">
91
+ ${allItems.map((c) => `
92
+ <li style="padding:8px 10px;background:var(--bg-primary);border-left:3px solid ${preflightStatusColor(c.status)};border-radius:var(--radius-sm);">
93
+ <div style="font-weight:600;color:var(--text);font-size:0.85rem;">
94
+ ${preflightStatusIcon(c.status)} ${esc(c.label)} — <span style="color:var(--text-muted);font-weight:400;">${esc(c.detail || '')}</span>
95
+ </div>
96
+ ${c.consequence ? `<div style="font-size:0.8rem;color:var(--text);margin-top:4px;">${esc(c.consequence)}</div>` : ''}
97
+ </li>
98
+ `).join('')}
99
+ </ul>
100
+ </div>
101
+ <div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
102
+ <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>
103
+ ${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>`}
104
+ </div>
105
+ `;
106
+ if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
107
+ const cancel = dlg.querySelector('#preflight-cancel');
108
+ const proceed = dlg.querySelector('#preflight-proceed');
109
+ const close = (val) => { try { dlg.close(); } catch { /* ignore */ } resolve(val); };
110
+ cancel?.addEventListener('click', () => close(false), { once: true });
111
+ proceed?.addEventListener('click', () => close(true), { once: true });
112
+ dlg.addEventListener('close', () => resolve(false), { once: true });
113
+ });
114
+ }
115
+
116
+ async function runProject(projectId) {
117
+ try {
118
+ const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/run`, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({}),
122
+ });
123
+ if (!res.ok) {
124
+ if (res.status === 404) {
125
+ await showError(
126
+ 'The board server does not recognise the "run" endpoint.\n\n'
127
+ + 'Most likely cause: the board process is running a pre-v2.7.5 build. '
128
+ + 'Restart it with:\n\n'
129
+ + ' kj board stop && kj board start',
130
+ { title: 'Board out of date' }
131
+ );
132
+ return;
133
+ }
134
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
135
+ await showError(msg, { title: 'Could not launch run' });
136
+ return;
137
+ }
138
+ const body = await res.json();
139
+ // Best-effort: a chokidar tick may still be in flight, so fetch
140
+ // once explicitly to surface the "running" state without waiting
141
+ // for the watcher debounce.
142
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
143
+ await renderBoard();
144
+
145
+ // Remember the first successful planId so the section header can
146
+ // offer a "View log" button without the user having to re-launch.
147
+ const firstOk = (body.results || []).find((r) => r.ok);
148
+ if (firstOk && firstOk.planId) {
149
+ lastLaunchedPlanId = firstOk.planId;
150
+ lastOpenedLog = {
151
+ id: firstOk.planId,
152
+ label: 'Run log',
153
+ tailUrl: (offset) => `/api/plans/${encodeURIComponent(firstOk.planId)}/log?offset=${offset}`,
154
+ };
155
+ // Open the log viewer straight away — the user's mental model is
156
+ // "I clicked Run, I want to see what's happening", not "I clicked
157
+ // Run, now where do I look".
158
+ openLogViewer(firstOk.planId);
159
+ } else {
160
+ await showError(
161
+ `Pipeline launched for ${body.launched}/${body.total} plan(s) but `
162
+ + 'no planId came back, so there\'s no log to show.',
163
+ { title: 'Run started' }
164
+ );
165
+ }
166
+ } catch (err) {
167
+ await showError(err.message, { title: 'Could not launch run' });
168
+ }
169
+ }
@@ -0,0 +1,252 @@
1
+ // KJC-TSK-0501 step 8g/8 — Server-push updates (SSE → smart DOM patch).
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so
4
+ // the top-level `function` / `let` declarations are hoisted into the
5
+ // shared realm and `subscribeToServerEvents` is callable from app.js's
6
+ // DOMContentLoaded handler.
7
+ //
8
+ // Globals consumed from earlier scripts / app.js:
9
+ // - showPromptModal() (utils/modals.js)
10
+ // - closePromptModalIfMatches() (utils/modals.js)
11
+ // - render() (app.js)
12
+ // - api() (utils/api.js)
13
+ // - renderStoryCard() (utils/board-view.js)
14
+ // - cssEscape() (utils/formatters.js)
15
+ // - currentView (mutable in app.js — read here)
16
+ // - selectedProject (mutable in app.js — read here)
17
+
18
+ // ---- Server-push updates ----
19
+ //
20
+ // The board gets a one-way stream of events from the server via
21
+ // Server-Sent Events; each event hints at what changed (plan file,
22
+ // session, batch). On receipt we refresh the current view in a way
23
+ // that preserves scroll position and any open modal. Full-page
24
+ // re-render is no longer on the table — clicking around doesn't
25
+ // reset your scroll anymore.
26
+ //
27
+ // EventSource auto-reconnects on drop, so we don't manage lifecycle
28
+ // ourselves. The status line below the header flashes briefly on
29
+ // each update so the user can see the data is live.
30
+
31
+ let sseSource = null;
32
+ let sseRefreshTimer = null;
33
+
34
+ function subscribeToServerEvents() {
35
+ if (typeof EventSource === 'undefined') return; // ancient browser
36
+ if (sseSource) sseSource.close();
37
+ sseSource = new EventSource('/api/events');
38
+ sseSource.addEventListener('message', (e) => {
39
+ // Try to parse the event as a typed payload. Prompt events drive
40
+ // the prompt modal; everything else just nudges a re-render.
41
+ let payload;
42
+ try { payload = JSON.parse(e.data); } catch { payload = null; }
43
+
44
+ if (payload?.type === 'prompt' && payload.promptId) {
45
+ // A non-interactive runner is asking for input — pop the modal
46
+ // immediately. fetch the full prompt body from the API so we
47
+ // don't depend on the SSE event carrying every field.
48
+ fetch(`/api/prompts`).then(r => r.ok ? r.json() : []).then((list) => {
49
+ const match = list.find((p) => p.promptId === payload.promptId);
50
+ if (match) showPromptModal(match);
51
+ }).catch(() => {});
52
+ return;
53
+ }
54
+ if (payload?.type === 'prompt-resolved' && payload.promptId) {
55
+ // Runner read its answer file — close the modal if it's still open.
56
+ closePromptModalIfMatches(payload.promptId);
57
+ return;
58
+ }
59
+
60
+ // Default: data changed somewhere. Try the targeted DOM patch
61
+ // first (no parpadeo, preserves scroll + hover state) and only
62
+ // fall back to a full re-render when the patch can't apply
63
+ // (different view, missing markers, etc.). This is the fix for
64
+ // the "todo el board parpadea cada vez que algo cambia" UX bug.
65
+ if (sseRefreshTimer) clearTimeout(sseRefreshTimer);
66
+ sseRefreshTimer = setTimeout(() => smartRefresh(payload), 150);
67
+ });
68
+ sseSource.addEventListener('error', () => {
69
+ // EventSource handles reconnection. Nothing to do; browsers retry
70
+ // the `retry:` value we sent on open.
71
+ });
72
+
73
+ // On boot, also fetch any prompts that were already pending before
74
+ // this tab connected (we missed their SSE add-event).
75
+ fetch('/api/prompts').then((r) => r.ok ? r.json() : []).then((list) => {
76
+ for (const p of list) showPromptModal(p);
77
+ }).catch(() => {});
78
+ }
79
+
80
+ /**
81
+ * Refresh the current SPA view without touching scroll position or
82
+ * any modal state. The heavy lifting already lives in renderBoard /
83
+ * renderGraph / renderSessions; this just re-calls them while the
84
+ * scroll offset is pinned, so the visual effect is "the card
85
+ * status updated in place".
86
+ * @param {string} _rawEvent unused for now — we could branch on
87
+ * event type later to patch only the touched card, but a full
88
+ * re-render with scroll preservation is already invisible to
89
+ * the user and a lot simpler to reason about.
90
+ */
91
+ async function refreshCurrentView(_rawEvent) {
92
+ const app = document.getElementById('app');
93
+ const scrollTop = app?.scrollTop ?? 0;
94
+ const scrollLeft = app?.scrollLeft ?? 0;
95
+ // Modal is in a separate DOM branch (#modal-backdrop) so render()
96
+ // never touches it — we don't need to save its state.
97
+ try {
98
+ await render();
99
+ } finally {
100
+ const fresh = document.getElementById('app');
101
+ if (fresh) {
102
+ fresh.scrollTop = scrollTop;
103
+ fresh.scrollLeft = scrollLeft;
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Smart refresh: pick the cheapest possible DOM update for the
110
+ * incoming SSE event. Today the server sends coarse-grained events
111
+ * (`{type: 'plan'|'batch'|'session'|'prompt'|...}`), so we route by
112
+ * type:
113
+ *
114
+ * plan / batch on the board view → patchBoardIncremental()
115
+ * anything else → refreshCurrentView() (full re-render)
116
+ *
117
+ * The patch path preserves DOM nodes (no innerHTML rewrite, no
118
+ * scroll reset, no hover/focus loss) — this is what kills the
119
+ * "parpadeo" the user reported.
120
+ *
121
+ * @param {object|null} payload The parsed SSE payload, or null when
122
+ * the event wasn't valid JSON. Coarse types still work (we just
123
+ * degrade to a full re-render).
124
+ */
125
+ async function smartRefresh(payload) {
126
+ const onBoard = currentView === 'board' && Boolean(selectedProject);
127
+ const isStoryRelated = payload && ['plan', 'batch'].includes(payload.type);
128
+ if (onBoard && isStoryRelated) {
129
+ const ok = await patchBoardIncremental();
130
+ if (ok) return;
131
+ }
132
+ await refreshCurrentView();
133
+ }
134
+
135
+ /**
136
+ * Targeted DOM patch for the Kanban board. Re-fetches the project's
137
+ * stories, diffs them against the cards already rendered, and only
138
+ * touches the affected nodes:
139
+ *
140
+ * - status changed → move the card to the new column with a
141
+ * subtle animation (no DOM recreation, listeners survive)
142
+ * - new story → render + insert into the right column
143
+ * - story removed → fade + remove
144
+ * - column counts and the running badge are updated in place
145
+ *
146
+ * Returns false if the board's DOM markers aren't present (different
147
+ * view, error state, etc.) so smartRefresh can fall back to a full
148
+ * re-render.
149
+ *
150
+ * @returns {Promise<boolean>}
151
+ */
152
+ async function patchBoardIncremental() {
153
+ const kanban = document.querySelector('.kanban');
154
+ if (!kanban) return false; // not on the board view
155
+ const columns = {};
156
+ for (const col of kanban.querySelectorAll('.kanban__column')) {
157
+ const cls = col.dataset.column;
158
+ if (cls) columns[cls] = col;
159
+ }
160
+ if (!columns.pending || !columns.running) return false;
161
+
162
+ let stories;
163
+ try {
164
+ stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
165
+ } catch { return false; }
166
+
167
+ // Re-derive the column → status mapping (kept in lockstep with
168
+ // renderBoard's `columns` object — change there, change here).
169
+ const statusToColumn = (status) => {
170
+ if (['pending', 'certified', 'needs_context', 'blocked'].includes(status)) return 'pending';
171
+ if (['coding', 'reviewing'].includes(status)) return 'running';
172
+ if (status === 'done') return 'done';
173
+ if (status === 'failed') return 'failed';
174
+ return 'pending'; // safe default
175
+ };
176
+
177
+ const seen = new Set();
178
+ for (const story of stories) {
179
+ seen.add(story.id);
180
+ const targetCol = columns[statusToColumn(story.status)];
181
+ if (!targetCol) continue;
182
+ const existing = kanban.querySelector(`.story-card[data-story-id="${cssEscape(story.id)}"]`);
183
+ if (!existing) {
184
+ // New story — append to its column.
185
+ const wrapper = document.createElement('div');
186
+ wrapper.innerHTML = renderStoryCard(story).trim();
187
+ const node = wrapper.firstElementChild;
188
+ if (node) {
189
+ node.style.opacity = '0';
190
+ targetCol.appendChild(node);
191
+ requestAnimationFrame(() => { node.style.transition = 'opacity 200ms'; node.style.opacity = '1'; });
192
+ }
193
+ continue;
194
+ }
195
+ const previousStatus = existing.dataset.status;
196
+ if (previousStatus !== story.status) {
197
+ // Status changed — move the node + replace its inner content
198
+ // (counters, time-ago, status pill) without recreating the
199
+ // outer node so click handlers + transitions survive.
200
+ const wrapper = document.createElement('div');
201
+ wrapper.innerHTML = renderStoryCard(story).trim();
202
+ const fresh = wrapper.firstElementChild;
203
+ if (fresh) {
204
+ existing.innerHTML = fresh.innerHTML;
205
+ existing.dataset.status = story.status;
206
+ existing.setAttribute('onclick', fresh.getAttribute('onclick') || '');
207
+ if (existing.parentElement !== targetCol) {
208
+ existing.style.transition = 'opacity 150ms';
209
+ existing.style.opacity = '0.3';
210
+ setTimeout(() => {
211
+ targetCol.appendChild(existing);
212
+ requestAnimationFrame(() => { existing.style.opacity = '1'; });
213
+ }, 150);
214
+ }
215
+ }
216
+ } else {
217
+ // Same status — just refresh the inner block (counters / time
218
+ // ago / antipatterns may have changed) without moving.
219
+ const wrapper = document.createElement('div');
220
+ wrapper.innerHTML = renderStoryCard(story).trim();
221
+ const fresh = wrapper.firstElementChild;
222
+ if (fresh && fresh.innerHTML !== existing.innerHTML) {
223
+ existing.innerHTML = fresh.innerHTML;
224
+ }
225
+ }
226
+ }
227
+ // Cards that no longer exist on the server → remove with a fade.
228
+ for (const node of kanban.querySelectorAll('.story-card')) {
229
+ if (!seen.has(node.dataset.storyId)) {
230
+ node.style.transition = 'opacity 150ms';
231
+ node.style.opacity = '0';
232
+ setTimeout(() => node.remove(), 160);
233
+ }
234
+ }
235
+ // Recount each column + update the "N running" badge in the header.
236
+ for (const [cls, col] of Object.entries(columns)) {
237
+ const counter = col.querySelector('[data-column-count]');
238
+ if (counter) counter.textContent = String(col.querySelectorAll('.story-card').length);
239
+ col.style.opacity = col.querySelectorAll('.story-card').length === 0 ? '0.55' : '1';
240
+ }
241
+ const runningCount = columns.running.querySelectorAll('.story-card').length;
242
+ const runningBadge = document.querySelector('.section-header__badge');
243
+ if (runningBadge) {
244
+ if (runningCount > 0) {
245
+ runningBadge.textContent = `⚙ ${runningCount} running…`;
246
+ runningBadge.style.display = '';
247
+ } else {
248
+ runningBadge.style.display = 'none';
249
+ }
250
+ }
251
+ return true;
252
+ }