karajan-code 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +14 -6
  2. package/docs/README.es.md +1 -1
  3. package/package.json +8 -2
  4. package/packages/hu-board/package.json +3 -1
  5. package/packages/hu-board/public/app.js +81 -4025
  6. package/packages/hu-board/public/index.html +21 -0
  7. package/packages/hu-board/public/styles.css +32 -0
  8. package/packages/hu-board/public/utils/api.js +139 -0
  9. package/packages/hu-board/public/utils/board-view.js +462 -0
  10. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  11. package/packages/hu-board/public/utils/config-editor.js +181 -0
  12. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  13. package/packages/hu-board/public/utils/formatters.js +273 -0
  14. package/packages/hu-board/public/utils/graph-view.js +216 -0
  15. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  16. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  17. package/packages/hu-board/public/utils/log-panel.js +222 -0
  18. package/packages/hu-board/public/utils/modals.js +290 -0
  19. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  20. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  21. package/packages/hu-board/public/utils/project-actions.js +143 -0
  22. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  23. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  24. package/packages/hu-board/public/utils/server-push.js +252 -0
  25. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  26. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  27. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  28. package/packages/hu-board/public/wiki.html +32 -0
  29. package/packages/hu-board/public/wiki.js +53 -0
  30. package/packages/hu-board/src/config-yaml.js +14 -0
  31. package/packages/hu-board/src/db.js +93 -2
  32. package/packages/hu-board/src/format.js +79 -0
  33. package/packages/hu-board/src/plan-mutations.js +4 -4
  34. package/packages/hu-board/src/routes/api.js +43 -10
  35. package/packages/hu-board/src/routes/rag.js +1 -1
  36. package/packages/hu-board/src/routes/wiki.js +43 -0
  37. package/packages/hu-board/src/run-tracker.js +1 -1
  38. package/packages/hu-board/src/server.js +4 -2
  39. package/packages/hu-board/src/sync.js +1 -1
  40. package/scripts/audit-test-diet.mjs +151 -0
  41. package/scripts/git-hooks/post-merge +13 -2
  42. package/src/audit/ai-slop-findings.js +102 -0
  43. package/src/audit/deterministic-summary.js +18 -0
  44. package/src/audit/harness-scorecard.js +52 -3
  45. package/src/audit/harness-section.js +6 -2
  46. package/src/brain/standby-scheduler.js +10 -92
  47. package/src/brain/standby-store.js +12 -168
  48. package/src/checks/ai-trash.js +34 -0
  49. package/src/checks/qmd.js +41 -0
  50. package/src/checks/runner.js +10 -0
  51. package/src/checks/squeezr.js +36 -0
  52. package/src/cli/register-meta.js +36 -2
  53. package/src/cli/register-pipeline.js +3 -0
  54. package/src/cli/register-plan.js +10 -1
  55. package/src/commands/audit.js +6 -4
  56. package/src/commands/board.js +34 -0
  57. package/src/commands/clean.js +62 -0
  58. package/src/commands/doctor.js +6 -0
  59. package/src/commands/init.js +73 -8
  60. package/src/commands/plan/generate.js +9 -3
  61. package/src/commands/qmd.js +86 -0
  62. package/src/commands/resume.js +6 -45
  63. package/src/commands/run.js +6 -50
  64. package/src/commands/sync.js +30 -2
  65. package/src/config/defaults.js +5 -0
  66. package/src/config/schema.js +2 -0
  67. package/src/git/hu-snapshot.js +10 -105
  68. package/src/orchestrator/config-init.js +1 -0
  69. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  70. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
  71. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  72. package/src/orchestrator/hu-sub-pipeline.js +52 -8
  73. package/src/orchestrator/stages/coder-stage.js +8 -2
  74. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  75. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  76. package/src/plan/plan-hu-ops.js +16 -338
  77. package/src/plan/plan-id.js +4 -45
  78. package/src/plan/plan-validation.js +4 -82
  79. package/src/prompts/tool-correctness-judge.js +31 -0
  80. package/src/rag/vec-store.js +17 -253
  81. package/src/review/git-stash-helper.js +104 -0
  82. package/src/review/tdd-discipline.js +90 -0
  83. package/src/roles/audit-role.js +11 -1
  84. package/src/roles/tool-correctness-judge-role.js +78 -0
  85. package/src/session/mutators.js +4 -0
  86. package/src/sync/apply-canvas-patch.js +55 -0
  87. package/src/utils/ai-trash.js +48 -0
  88. package/src/utils/atomic-write.js +6 -43
  89. package/src/utils/cli-ask-question.js +76 -0
  90. package/src/utils/doc-links.js +2 -0
  91. package/src/utils/os-detect.js +10 -0
  92. package/src/utils/paths.js +14 -111
  93. package/src/utils/port-check.js +6 -45
  94. package/src/utils/process.js +4 -188
  95. package/src/utils/qmd-collection.js +72 -0
  96. package/src/utils/qmd-detect.js +18 -0
  97. package/src/utils/qmd-install.js +37 -0
  98. package/src/utils/repo-cleaner.js +79 -0
  99. package/src/utils/run-registry.js +9 -116
  100. package/src/utils/shared-paths.js +12 -41
  101. package/src/utils/squeezr-detect.js +18 -0
  102. package/src/utils/squeezr-install.js +37 -0
  103. package/src/utils/tool-call-extractor.js +56 -0
  104. package/src/utils/vector-store-orphans.js +89 -0
@@ -0,0 +1,224 @@
1
+ // KJC-TSK-0501 step 8a/8 — HU action handlers (per-card and per-modal actions).
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so the
4
+ // `window.*` assignments are in place by the time the kanban mounts.
5
+ // Inline `onclick="..."` attributes emitted by board-view.js / dashboard-
6
+ // view.js / story-detail-view.js resolve these handlers via `window`.
7
+ //
8
+ // Globals consumed from earlier scripts / app.js:
9
+ // - showConfirm() (utils/modals.js)
10
+ // - showError() (utils/modals.js)
11
+ // - closeModal() (app.js → window.closeModal)
12
+ // - renderBoard() (app.js)
13
+ // - openGenericLogPanel() (utils/log-panel.js)
14
+ // - lastOpenedLog (mutable in app.js — assignment crosses scripts
15
+ // transparently because both share the same realm)
16
+
17
+ /**
18
+ * PR4: launch a single HU from the per-card ▶ button. Confirms with
19
+ * the user (so an accidental click doesn't kick a run) and POSTs to
20
+ * the new /api/hus/:huId/run endpoint, which resolves the planId
21
+ * server-side from the SQLite row.
22
+ *
23
+ * Exposed on `window` because the inline onclick on the card calls it.
24
+ */
25
+ window.runSingleHuFromCard = async function runSingleHuFromCard(huId, title) {
26
+ const friendlyTitle = (title || huId).replace(/'/g, "'");
27
+ const ok = await showConfirm(
28
+ `¿Lanzar solo esta HU?\n\n${friendlyTitle}\n\nKarajan ejecutará únicamente esta HU; las demás del plan no se tocarán.`,
29
+ { title: 'Lanzar HU individual', okLabel: 'Lanzar', cancelLabel: 'Cancelar' }
30
+ );
31
+ if (!ok) return;
32
+ try {
33
+ const res = await fetch(`/api/hus/${encodeURIComponent(huId)}/run`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({}),
37
+ });
38
+ if (!res.ok) {
39
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
40
+ await showError(msg, { title: 'No se pudo lanzar la HU' });
41
+ return;
42
+ }
43
+ const body = await res.json();
44
+ // Surface the log path so the user can tail it from the View log button
45
+ // AND auto-open the viewer so the click produces visible feedback. Without
46
+ // the open, the user clicks Lanzar, sees nothing change, and concludes the
47
+ // run never started — the run IS started, the log IS being written, the
48
+ // UI just wasn't showing it.
49
+ //
50
+ // The tail URL must be derived from body.logPath, not constructed from
51
+ // huId. When `kj run --plan ... --hu <id>` is launched, runPlan writes to
52
+ // <runsDir>/<planId>--hu-<localHuId>.log
53
+ // — not <runsDir>/hu-<huId>.log. The previous code guessed the wrong
54
+ // filename and the viewer would have hit a non-existent file even if
55
+ // it had been opened.
56
+ if (body.logPath) {
57
+ const logBasename = body.logPath.split('/').pop().replace(/\.log$/, '');
58
+ const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
59
+ lastOpenedLog = { id: `hu-${huId}`, label: `HU ${huId}`, tailUrl };
60
+ openGenericLogPanel({ id: logBasename, label: `HU ${huId}`, tailUrl });
61
+ }
62
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
63
+ await renderBoard();
64
+ } catch (err) {
65
+ await showError(err.message || String(err), { title: 'Fallo al lanzar la HU' });
66
+ }
67
+ };
68
+
69
+ /**
70
+ * KJC-TSK-0394 step 2: devolver una HU al estado `pending` desde
71
+ * cualquier estado no-terminal (coding/reviewing/blocked zombi) o
72
+ * terminal (done/failed) que el usuario quiera relanzar limpio.
73
+ *
74
+ * No tocamos el campo `result`: si una HU acaba en done+fail y el
75
+ * usuario hace Reset → status: pending, result: fail (badge ✗ persiste
76
+ * como historial). El siguiente run sobreescribirá result cuando
77
+ * termine.
78
+ *
79
+ * El backend ya acepta {status: 'pending'} en ALLOWED_STORY_STATUSES
80
+ * (ver routes/api.js); plan-mutations::setHuStatus refleja el cambio
81
+ * al fichero del plan y resincroniza la BBDD.
82
+ */
83
+ /**
84
+ * Cambio libre de status desde el dropdown del modal. PATCH al backend
85
+ * con el status nuevo + confirmación (algunos targets como `done` /
86
+ * `failed` son blast-radius alto: no se puede deshacer sin Reset).
87
+ *
88
+ * El propio backend valida que `newStatus` esté en ALLOWED_STORY_
89
+ * STATUSES, así que si el dropdown trae basura el servidor rechaza con
90
+ * 400 — no necesitamos re-validar aquí.
91
+ */
92
+ window.changeHuStatusFromModal = async function changeHuStatusFromModal(storyId, newStatus, oldStatus) {
93
+ // Opción "Cambiar a…" placeholder: el usuario abrió el dropdown sin
94
+ // elegir nada. Selecciona el placeholder otra vez para no quedar en
95
+ // un estado ambiguo.
96
+ if (!newStatus) return;
97
+ const select = document.getElementById('hu-status-select');
98
+ const ok = await showConfirm(
99
+ `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.`,
100
+ { title: 'Cambiar status', okLabel: 'Cambiar', cancelLabel: 'Cancelar' }
101
+ );
102
+ if (!ok) { if (select) select.value = ''; return; }
103
+ try {
104
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
105
+ method: 'PATCH',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ status: newStatus }),
108
+ });
109
+ if (!res.ok) {
110
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
111
+ await showError(msg, { title: 'No se pudo cambiar el status' });
112
+ if (select) select.value = '';
113
+ return;
114
+ }
115
+ closeModal();
116
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
117
+ await renderBoard();
118
+ } catch (err) {
119
+ await showError(err.message || String(err), { title: 'Fallo al cambiar el status' });
120
+ if (select) select.value = '';
121
+ }
122
+ };
123
+
124
+ // KJC-TSK-0406: persist coder_model + reviewer_model overrides from the
125
+ // modal. Vacío → null (re-asignación automática por triage en siguiente
126
+ // run). Cada modelo es independiente.
127
+ window.saveHuModels = async function saveHuModels(storyId) {
128
+ const coderInput = document.getElementById('hu-coder-model');
129
+ const reviewerInput = document.getElementById('hu-reviewer-model');
130
+ const patch = {
131
+ coder_model: coderInput?.value?.trim() || null,
132
+ reviewer_model: reviewerInput?.value?.trim() || null,
133
+ };
134
+ try {
135
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
136
+ method: 'PATCH',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify(patch),
139
+ });
140
+ if (!res.ok) {
141
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
142
+ await showError(msg, { title: 'No se pudieron guardar los modelos' });
143
+ return;
144
+ }
145
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
146
+ await renderBoard();
147
+ } catch (err) {
148
+ await showError(err.message || String(err), { title: 'Fallo guardando modelos' });
149
+ }
150
+ };
151
+
152
+ // KJC-PRP-0002 PR6: persist `hu.assignee` (free-form handle of whoever owns
153
+ // this HU on a team-shared board). Empty string → null so the API knows to
154
+ // clear, not store "". The modal only renders the input when the project is
155
+ // `is_shared = 1`, so non-team boards never trigger this path.
156
+ window.saveHuAssignee = async function saveHuAssignee(storyId) {
157
+ const input = document.getElementById('hu-assignee');
158
+ const value = input?.value?.trim() || null;
159
+ try {
160
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
161
+ method: 'PATCH',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ assignee: value }),
164
+ });
165
+ if (!res.ok) {
166
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
167
+ await showError(msg, { title: 'No se pudo guardar el asignado' });
168
+ return;
169
+ }
170
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
171
+ await renderBoard();
172
+ } catch (err) {
173
+ await showError(err.message || String(err), { title: 'Fallo guardando asignado' });
174
+ }
175
+ };
176
+
177
+ // KJC-TSK-0408: deshace los cambios de una HU restaurando el snapshot
178
+ // git tomado pre-run. ATENCIÓN: es destructivo (git reset --hard) —
179
+ // confirmamos siempre antes de invocar.
180
+ window.undoHuChanges = async function undoHuChanges(storyId) {
181
+ const ok = await showConfirm(
182
+ '⚠️ 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?',
183
+ { title: 'Deshacer cambios de la HU', okLabel: 'Deshacer', cancelLabel: 'Cancelar' }
184
+ );
185
+ if (!ok) return;
186
+ try {
187
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}/undo`, { method: 'POST' });
188
+ if (!res.ok) {
189
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
190
+ await showError(msg, { title: 'No se pudo deshacer la HU' });
191
+ return;
192
+ }
193
+ closeModal();
194
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
195
+ await renderBoard();
196
+ } catch (err) {
197
+ await showError(err.message || String(err), { title: 'Fallo al deshacer la HU' });
198
+ }
199
+ };
200
+
201
+ window.resetHuToPending = async function resetHuToPending(storyId) {
202
+ const ok = await showConfirm(
203
+ '¿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.',
204
+ { title: 'Reset HU', okLabel: 'Reset', cancelLabel: 'Cancelar' }
205
+ );
206
+ if (!ok) return;
207
+ try {
208
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
209
+ method: 'PATCH',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ status: 'pending' }),
212
+ });
213
+ if (!res.ok) {
214
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
215
+ await showError(msg, { title: 'No se pudo resetear la HU' });
216
+ return;
217
+ }
218
+ closeModal();
219
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
220
+ await renderBoard();
221
+ } catch (err) {
222
+ await showError(err.message || String(err), { title: 'Fallo al resetear la HU' });
223
+ }
224
+ };
@@ -0,0 +1,180 @@
1
+ // KJC-TSK-0501 step 8h/8 — Initialization listeners + boot sequence.
2
+ //
3
+ // Classic script (no exports). Loaded by index.html AFTER all other
4
+ // utils/*.js (including utils/server-push.js) so every globally
5
+ // declared function and `let` is available when these handlers and
6
+ // the boot block run.
7
+ //
8
+ // Globals consumed from earlier scripts / app.js:
9
+ // - navigate(), render(), handleRoute(),
10
+ // populateProjectSelect() (app.js)
11
+ // - showCommandLauncher(),
12
+ // nextIsTestValue() (utils/command-launcher.js)
13
+ // - showHelp(), showConfirm(),
14
+ // showError(), closeModal() (utils/modals.js)
15
+ // - showConfigEditor() (utils/config-editor.js)
16
+ // - showStoryDetail() (utils/story-detail-view.js)
17
+ // - showSessionDetail() (utils/sessions-view.js)
18
+ // - selectProject() (app.js)
19
+ // - triggerSync(),
20
+ // startStandbyPolling() (utils/api.js)
21
+ // - subscribeToServerEvents(),
22
+ // smartRefresh() (utils/server-push.js)
23
+ // - selectedProject, currentView,
24
+ // refreshInterval (mutable `let` in app.js —
25
+ // assignment crosses scripts via
26
+ // shared-realm hoisting)
27
+
28
+ // ---- Initialization ----
29
+
30
+ // Nav button clicks
31
+ document.querySelectorAll('.nav-btn').forEach((btn) => {
32
+ btn.addEventListener('click', () => navigate(btn.dataset.view));
33
+ });
34
+
35
+ // Project selector
36
+ document.getElementById('project-select').addEventListener('change', (e) => {
37
+ selectedProject = e.target.value;
38
+ window.location.hash = selectedProject ? `${currentView}/${selectedProject}` : currentView;
39
+ render();
40
+ });
41
+
42
+ // Sync button — re-scan disk for new batches
43
+ document.getElementById('sync-btn').addEventListener('click', async () => {
44
+ const btn = document.getElementById('sync-btn');
45
+ btn.disabled = true;
46
+ btn.textContent = '⏳';
47
+ try {
48
+ await fetch('/api/sync', { method: 'POST' });
49
+ await populateProjectSelect();
50
+ render();
51
+ } catch { /* ignore */ }
52
+ btn.textContent = '🔄';
53
+ btn.disabled = false;
54
+ });
55
+
56
+ // Commands button — opens the launcher modal so the user can run
57
+ // `kj plan`, `kj architect`, `kj clean` (and more later) without
58
+ // dropping to a terminal. Output streams to the existing log panel.
59
+ document.getElementById('commands-btn').addEventListener('click', () => {
60
+ showCommandLauncher();
61
+ });
62
+
63
+ // Help button — KJC-TSK-0372. Quick reference modal for the 5 views.
64
+ // Hover tooltips on each tab cover the 1-line case; this modal is
65
+ // for users who arrive cold and want "ok, what does each one do?".
66
+ document.getElementById('help-btn').addEventListener('click', () => {
67
+ showHelp();
68
+ });
69
+
70
+ // PR5: Settings button — opens the kj.config.yml editor modal.
71
+ // User picks coder/reviewer providers, model strategy, iteration
72
+ // caps, etc. in plain Spanish. Save writes the yml atomically;
73
+ // next `kj run` picks up the new config (no restart needed).
74
+ document.getElementById('config-btn').addEventListener('click', () => {
75
+ showConfigEditor();
76
+ });
77
+
78
+ // Restart button — respawn the board server in place. The page's
79
+ // EventSource auto-reconnects so the user sees a brief "connecting…"
80
+ // blip and the board comes back live.
81
+ document.getElementById('restart-btn').addEventListener('click', async () => {
82
+ const ok = await showConfirm(
83
+ 'Restart the HU Board server now? The page will reconnect automatically.',
84
+ { title: 'Restart board', okLabel: 'Restart' }
85
+ );
86
+ if (!ok) return;
87
+ try {
88
+ await fetch('/api/board/restart', { method: 'POST' });
89
+ } catch { /* the server is going down; the catch is expected */ }
90
+ // Give the new server a beat to bind, then reload the page.
91
+ setTimeout(() => window.location.reload(), 1200);
92
+ });
93
+
94
+ document.addEventListener('click', async (e) => {
95
+ const btn = e.target.closest('.project-card__is-test');
96
+ if (!btn) return;
97
+ e.stopPropagation();
98
+ e.preventDefault();
99
+ const projectId = btn.dataset.projectId;
100
+ const next = nextIsTestValue(btn.dataset.isTest);
101
+ try {
102
+ const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/is-test`, {
103
+ method: 'PATCH',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({ is_test: next }),
106
+ });
107
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
108
+ // Re-render the project list so the badge reflects the new value.
109
+ await populateProjectSelect();
110
+ render();
111
+ } catch (err) {
112
+ await showError(err.message, { title: 'Failed to update is_test' });
113
+ }
114
+ });
115
+
116
+ // Delete project (cascade) — delegated handler on the dashboard grid
117
+ document.addEventListener('click', async (e) => {
118
+ const btn = e.target.closest('.project-card__delete');
119
+ if (!btn) return;
120
+ e.stopPropagation();
121
+ e.preventDefault();
122
+ const projectId = btn.dataset.projectId;
123
+ const projectName = btn.dataset.projectName || projectId;
124
+ const ok = await showConfirm(
125
+ `Delete project "${projectName}" and all its stories + sessions?\n\n`
126
+ + `Also removes ~/.karajan/hu-stories/${projectId}/ from disk.`,
127
+ { title: 'Delete project', okLabel: 'Delete', destructive: true }
128
+ );
129
+ if (!ok) return;
130
+ try {
131
+ const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
132
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
133
+ await populateProjectSelect();
134
+ render();
135
+ } catch (err) {
136
+ await showError(err.message, { title: 'Failed to delete project' });
137
+ }
138
+ });
139
+
140
+ // Modal close on backdrop click
141
+ document.getElementById('modal-backdrop').addEventListener('click', (e) => {
142
+ if (e.target === e.currentTarget) closeModal();
143
+ });
144
+
145
+ // ESC to close modal
146
+ document.addEventListener('keydown', (e) => {
147
+ if (e.key === 'Escape') closeModal();
148
+ });
149
+
150
+ // Hash routing
151
+ window.addEventListener('hashchange', handleRoute);
152
+
153
+ // Make functions available globally for onclick handlers
154
+ window.showStoryDetail = showStoryDetail;
155
+ window.showSessionDetail = showSessionDetail;
156
+ window.closeModal = closeModal;
157
+ window.selectProject = selectProject;
158
+
159
+ // KJC-TSK-0414 PR4: arranca el polling del banner de standby al cargar la página.
160
+ startStandbyPolling();
161
+
162
+ // Initial load — sync disk data first so new batches are visible
163
+ triggerSync().then(() => {
164
+ populateProjectSelect();
165
+ handleRoute();
166
+ subscribeToServerEvents();
167
+ });
168
+
169
+ // Auto-refresh every 60 seconds as a SAFETY NET only — the board is
170
+ // SSE-driven (push from server) so this should rarely fire. We keep
171
+ // it at low frequency to recover from missed events on hibernation /
172
+ // network blip without flickering the UI every 10s like before.
173
+ // Uses smartRefresh too so it patches the DOM instead of full rerender.
174
+ refreshInterval = setInterval(async () => {
175
+ if (document.getElementById('modal-backdrop').classList.contains('hidden')) {
176
+ await triggerSync();
177
+ await populateProjectSelect();
178
+ await smartRefresh(null);
179
+ }
180
+ }, 60_000);
@@ -0,0 +1,222 @@
1
+ // KJC-TSK-0501 step 7/8 — Floating run-log panel + window controls.
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so
4
+ // the function declarations hoist and inline onclick="openLogViewer(...)"
5
+ // handlers emitted by board-view / dashboard-view resolve transparently.
6
+ // `logPollTimer` and `logViewerState` are top-level `let` bindings in
7
+ // this classic script — same realm as app.js, so the cross-script
8
+ // references keep working unchanged.
9
+ //
10
+ // Globals consumed from earlier scripts:
11
+ // - esc() (app.js)
12
+ // - ansiToHtml() (utils/formatters.js)
13
+
14
+ // `logPollTimer` survives multiple sessions because the panel is a
15
+ // singleton — opening a second log tears the first one down. `panel`
16
+ // is kept on `logViewerState` so the close handler can null it out
17
+ // from inside the tail loop (without it, `tick()` would keep firing
18
+ // HTTP requests against a dead panel).
19
+ let logPollTimer = null;
20
+ let logViewerState = { planId: null, panel: null, offset: 0, isMax: false, isMin: false };
21
+
22
+ /**
23
+ * Open (or refocus) the run-log panel for the given plan. Thin shim
24
+ * over `openGenericLogPanel` — the original entry point used for
25
+ * `kj run --plan` runs that the Run-plan button kicks off.
26
+ */
27
+ function openLogViewer(planId) {
28
+ return openGenericLogPanel({
29
+ id: planId,
30
+ label: 'Run log',
31
+ tailUrl: (offset) => `/api/plans/${encodeURIComponent(planId)}/log?offset=${offset}`,
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Generic floating log panel anchored bottom-right. Used by:
37
+ * - openLogViewer(planId) → /api/plans/:planId/log
38
+ * - openCommandLogViewer(cmd, id) → /api/runs/:commandId/log
39
+ *
40
+ * The only thing that changes between callers is the tail URL +
41
+ * the label; the window controls (min/max/close), polling, and ANSI
42
+ * rendering are identical.
43
+ *
44
+ * @param {object} args
45
+ * @param {string} args.id - opaque id (planId or commandId), shown in header
46
+ * @param {string} args.label - "Run log", "kj plan", …
47
+ * @param {(offset:number)=>string} args.tailUrl - returns the URL to GET each tick
48
+ */
49
+ function openGenericLogPanel({ id, label, tailUrl }) {
50
+ // Tearing down the previous panel cleanly avoids two timers running
51
+ // when the user opens a second log while the first is still tailing.
52
+ if (logPollTimer) { clearTimeout(logPollTimer); logPollTimer = null; }
53
+ if (logViewerState.panel && logViewerState.panel.parentNode) {
54
+ logViewerState.panel.remove();
55
+ }
56
+
57
+ const panel = document.createElement('section');
58
+ panel.id = 'kj-log-panel';
59
+ panel.dataset.state = 'normal';
60
+ panel.style.cssText = [
61
+ 'position:fixed',
62
+ 'right:16px',
63
+ 'bottom:16px',
64
+ 'width:min(720px, calc(100vw - 32px))',
65
+ 'height:min(420px, calc(100vh - 80px))',
66
+ 'display:flex',
67
+ 'flex-direction:column',
68
+ 'border:1px solid var(--border)',
69
+ 'border-radius:var(--radius-sm)',
70
+ 'background:var(--bg-secondary)',
71
+ 'box-shadow:0 12px 40px rgba(0,0,0,0.45)',
72
+ 'z-index:9999',
73
+ 'overflow:hidden',
74
+ ].join(';');
75
+
76
+ panel.innerHTML = `
77
+ <header style="display:flex;align-items:center;justify-content:space-between;
78
+ padding:8px 12px;background:var(--bg-primary);
79
+ border-bottom:1px solid var(--border);
80
+ user-select:none;flex-shrink:0">
81
+ <div style="display:flex;align-items:baseline;gap:10px;min-width:0">
82
+ <strong style="color:var(--text)">${esc(label)}</strong>
83
+ <span style="font-family:var(--font-mono, monospace);font-size:0.78rem;
84
+ color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;
85
+ white-space:nowrap">${esc(id)}</span>
86
+ </div>
87
+ <div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
88
+ <span id="log-status" style="font-size:0.75rem;color:var(--text-muted);margin-right:8px">connecting…</span>
89
+ <button id="log-min" type="button" title="Minimize (run keeps going)"
90
+ style="${winBtnStyle('#facc15')}">_</button>
91
+ <button id="log-max" type="button" title="Maximize / Restore"
92
+ style="${winBtnStyle('#34d399')}">▢</button>
93
+ <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."
94
+ style="${winBtnStyle('#f87171')}">✕</button>
95
+ </div>
96
+ </header>
97
+ <pre id="log-body"
98
+ style="margin:0;padding:12px 16px;flex:1 1 auto;overflow:auto;
99
+ font-family:var(--font-mono, monospace);font-size:0.8rem;
100
+ line-height:1.45;background:#0b0b0c;color:#e6e6e6;
101
+ white-space:pre-wrap;word-break:break-word"></pre>
102
+ <footer style="padding:6px 12px;border-top:1px solid var(--border);
103
+ font-size:0.7rem;color:var(--text-muted);flex-shrink:0">
104
+ Closing this panel does NOT stop the run. It keeps going in the background;
105
+ reopen with the 📜 View log button on the board.
106
+ </footer>
107
+ `;
108
+
109
+ document.body.appendChild(panel);
110
+ logViewerState = { planId: id, panel, offset: 0, isMax: false, isMin: false };
111
+
112
+ const bodyEl = panel.querySelector('#log-body');
113
+ const statusEl = panel.querySelector('#log-status');
114
+ const footerEl = panel.querySelector('footer');
115
+ const minBtn = panel.querySelector('#log-min');
116
+ const maxBtn = panel.querySelector('#log-max');
117
+ const closeBtn = panel.querySelector('#log-close');
118
+
119
+ // ---- Window controls ----
120
+ closeBtn.addEventListener('click', () => {
121
+ if (logPollTimer) { clearTimeout(logPollTimer); logPollTimer = null; }
122
+ panel.remove();
123
+ logViewerState = { planId: null, panel: null, offset: 0, isMax: false, isMin: false };
124
+ });
125
+
126
+ minBtn.addEventListener('click', () => {
127
+ logViewerState.isMin = !logViewerState.isMin;
128
+ if (logViewerState.isMin) {
129
+ // Collapse to header-only pill anchored at bottom-right. Polling
130
+ // continues so the kB counter stays live; click min again to expand.
131
+ panel.style.height = 'auto';
132
+ panel.style.width = 'auto';
133
+ bodyEl.style.display = 'none';
134
+ footerEl.style.display = 'none';
135
+ panel.dataset.state = 'min';
136
+ } else {
137
+ panel.style.height = logViewerState.isMax ? 'calc(100vh - 32px)' : 'min(420px, calc(100vh - 80px))';
138
+ panel.style.width = logViewerState.isMax ? 'calc(100vw - 32px)' : 'min(720px, calc(100vw - 32px))';
139
+ bodyEl.style.display = '';
140
+ footerEl.style.display = '';
141
+ panel.dataset.state = logViewerState.isMax ? 'max' : 'normal';
142
+ }
143
+ });
144
+
145
+ maxBtn.addEventListener('click', () => {
146
+ logViewerState.isMin = false;
147
+ logViewerState.isMax = !logViewerState.isMax;
148
+ bodyEl.style.display = '';
149
+ footerEl.style.display = '';
150
+ if (logViewerState.isMax) {
151
+ panel.style.top = '16px';
152
+ panel.style.left = '16px';
153
+ panel.style.right = '16px';
154
+ panel.style.bottom = '16px';
155
+ panel.style.width = 'auto';
156
+ panel.style.height = 'auto';
157
+ panel.dataset.state = 'max';
158
+ } else {
159
+ panel.style.top = '';
160
+ panel.style.left = '';
161
+ panel.style.right = '16px';
162
+ panel.style.bottom = '16px';
163
+ panel.style.width = 'min(720px, calc(100vw - 32px))';
164
+ panel.style.height = 'min(420px, calc(100vh - 80px))';
165
+ panel.dataset.state = 'normal';
166
+ }
167
+ });
168
+
169
+ // ---- Tail loop ----
170
+ async function tick() {
171
+ if (!logViewerState.panel) return; // user closed
172
+ try {
173
+ const res = await fetch(tailUrl(logViewerState.offset));
174
+ if (!res.ok) {
175
+ statusEl.textContent = `error: HTTP ${res.status}`;
176
+ logPollTimer = setTimeout(tick, 4000);
177
+ return;
178
+ }
179
+ const body = await res.json();
180
+ if (!body.exists) {
181
+ statusEl.textContent = 'waiting for run to start…';
182
+ } else {
183
+ if (body.content) {
184
+ // Append rendered HTML so ANSI escape codes show as colour
185
+ // instead of literal `[1m…[0m` noise (the user's complaint).
186
+ // Auto-scroll only when the user was already at the bottom,
187
+ // so manual scrollback doesn't fight the tail.
188
+ const atBottom = bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 8;
189
+ bodyEl.insertAdjacentHTML('beforeend', ansiToHtml(body.content));
190
+ if (atBottom) bodyEl.scrollTop = bodyEl.scrollHeight;
191
+ }
192
+ logViewerState.offset = body.size;
193
+ statusEl.textContent = `${(body.size / 1024).toFixed(1)} KB — live`;
194
+ }
195
+ } catch (err) {
196
+ statusEl.textContent = `error: ${err.message}`;
197
+ }
198
+ logPollTimer = setTimeout(tick, 2000);
199
+ }
200
+
201
+ tick();
202
+ }
203
+
204
+ /** Inline style for the three window-control buttons in the log panel. */
205
+ function winBtnStyle(accentColor) {
206
+ return [
207
+ 'width:22px',
208
+ 'height:22px',
209
+ 'padding:0',
210
+ 'border:1px solid var(--border)',
211
+ `background:${accentColor}`,
212
+ 'color:#0b0b0c',
213
+ 'font-weight:700',
214
+ 'font-size:0.78rem',
215
+ 'line-height:20px',
216
+ 'border-radius:50%',
217
+ 'cursor:pointer',
218
+ 'display:inline-flex',
219
+ 'align-items:center',
220
+ 'justify-content:center',
221
+ ].join(';');
222
+ }