karajan-code 2.19.0 → 2.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +4 -0
  2. package/package.json +9 -1
  3. package/packages/hu-board/README.md +69 -0
  4. package/packages/hu-board/package.json +23 -0
  5. package/packages/hu-board/public/app.js +4038 -0
  6. package/packages/hu-board/public/index.html +55 -0
  7. package/packages/hu-board/public/pipeline.css +132 -0
  8. package/packages/hu-board/public/pipeline.html +48 -0
  9. package/packages/hu-board/public/pipeline.js +200 -0
  10. package/packages/hu-board/public/styles.css +918 -0
  11. package/packages/hu-board/src/auth.js +92 -0
  12. package/packages/hu-board/src/boot-info.js +16 -0
  13. package/packages/hu-board/src/cleanup-zombies.js +142 -0
  14. package/packages/hu-board/src/command-runner.js +134 -0
  15. package/packages/hu-board/src/config-yaml.js +285 -0
  16. package/packages/hu-board/src/db.js +739 -0
  17. package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
  18. package/packages/hu-board/src/event-bus.js +58 -0
  19. package/packages/hu-board/src/format.js +83 -0
  20. package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
  21. package/packages/hu-board/src/journal-parser.js +180 -0
  22. package/packages/hu-board/src/plan-mutations.js +422 -0
  23. package/packages/hu-board/src/preflight.js +285 -0
  24. package/packages/hu-board/src/routes/api.js +1271 -0
  25. package/packages/hu-board/src/routes/pipeline.js +45 -0
  26. package/packages/hu-board/src/run-tracker.js +121 -0
  27. package/packages/hu-board/src/server.js +435 -0
  28. package/packages/hu-board/src/sync.js +626 -0
  29. package/packages/hu-board/src/token-store.js +57 -0
  30. package/packages/hu-board/src/zombie-reaper.js +207 -0
  31. package/src/audit/webperf-input.js +4 -7
  32. package/src/checks/dir-setup.js +5 -3
  33. package/src/cli/register-pipeline.js +1 -0
  34. package/src/cli/register-plan.js +1 -0
  35. package/src/commands/plan/generate.js +15 -0
  36. package/src/commands/run.js +19 -1
  37. package/src/commands/webperf.js +4 -6
  38. package/src/config/role-resolver.js +3 -1
  39. package/src/mcp/handlers/direct-handlers.js +3 -0
  40. package/src/mcp/handlers/run-handler.js +5 -0
  41. package/src/mcp/tools.js +4 -2
  42. package/src/prompts/spec-reviewer.js +66 -0
  43. package/src/roles/perf-role.js +4 -4
  44. package/src/roles/spec-reviewer-role.js +106 -0
  45. package/src/sonar/api.js +17 -3
  46. package/src/sonar/token-recovery.js +67 -0
  47. package/src/spec-review/refine-loop.js +58 -0
  48. package/src/spec-review/run-spec-review.js +70 -0
  49. package/src/utils/board-prompt-bridge.js +4 -5
  50. package/src/utils/display/spec-findings.js +44 -0
  51. package/src/utils/paths.js +15 -0
  52. package/src/utils/run-registry.js +4 -4
  53. package/templates/roles/spec-reviewer.md +38 -0
@@ -0,0 +1,1271 @@
1
+ import { Router } from 'express';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn as spawnChild } from 'node:child_process';
6
+ import {
7
+ getDashboardStats,
8
+ getProjects,
9
+ getStoriesByProject,
10
+ getStoryDetail,
11
+ getSessionsByProject,
12
+ getSessionDetail,
13
+ deleteProject,
14
+ deleteStory,
15
+ deleteSession,
16
+ getKjHome,
17
+ getHuBoardRunsDir,
18
+ getStoryRow,
19
+ listPlanIdsForProject,
20
+ updateStoryStatus,
21
+ setProjectIsTest,
22
+ } from '../db.js';
23
+ import { fullScan } from '../sync.js';
24
+ import { setHuStatus, setHuFields, markPlanReady, runPlan, renameProject, revertHuFromSnapshot } from '../plan-mutations.js';
25
+ import { getActiveRuns } from '../run-tracker.js';
26
+ import { subscribe as subscribeEvents } from '../event-bus.js';
27
+ import { runKjCommand, listSupportedCommands } from '../command-runner.js';
28
+ import { runPreflight } from '../preflight.js';
29
+ import { readConfig, writeConfigPatch } from '../config-yaml.js';
30
+ import { BOOT_TIME, PKG_VERSION } from '../boot-info.js';
31
+ import { addTombstone, getDb, isTombstoned as isTombstonedFn, removeTombstone, listTombstones } from '../db.js';
32
+ import { publish as publishEvent } from '../event-bus.js';
33
+
34
+ const router = Router();
35
+
36
+ /**
37
+ * GET /api/version - Server version + boot timestamp.
38
+ * The client polls this every 30s; if `boot_time` differs from the first
39
+ * response, the server restarted and the client triggers a reload to pick
40
+ * up any new HTML/JS (KJC-TSK-0379).
41
+ */
42
+ router.get('/version', (_req, res) => {
43
+ res.set('Cache-Control', 'no-store');
44
+ res.json({ version: PKG_VERSION, boot_time: BOOT_TIME });
45
+ });
46
+
47
+ /**
48
+ * GET /api/standby — KJC-TSK-0414 PR4. Lista sesiones hibernadas.
49
+ * Devuelve [{ sessionId, planId, huId, role, cooldownUntil, reason, ... }]
50
+ * El front renderiza un badge "💤 standby · resume en HH:MM:SS" por sesión.
51
+ */
52
+ router.get('/standby', async (_req, res) => {
53
+ try {
54
+ const { listPendingStandby } = await import('../../../../src/brain/standby-store.js');
55
+ const sessions = listPendingStandby();
56
+ res.set('Cache-Control', 'no-store');
57
+ res.json({ sessions });
58
+ } catch (err) {
59
+ res.status(500).json({ error: err.message });
60
+ }
61
+ });
62
+
63
+ /**
64
+ * Resolve the hu-stories dir where batch.json files live.
65
+ */
66
+ function huStoriesDir() {
67
+ return path.join(getKjHome(), 'hu-stories');
68
+ }
69
+
70
+ /**
71
+ * Best-effort removal of the hu-stories/<id>/ directory.
72
+ */
73
+ function removeBatchDir(batchId) {
74
+ try {
75
+ const dir = path.join(huStoriesDir(), batchId);
76
+ fs.rmSync(dir, { recursive: true, force: true });
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * GET /api/dashboard - Global dashboard statistics.
85
+ */
86
+ router.get('/dashboard', (_req, res) => {
87
+ try {
88
+ const stats = getDashboardStats();
89
+ res.json(stats);
90
+ } catch (err) {
91
+ res.status(500).json({ error: err.message });
92
+ }
93
+ });
94
+
95
+ /**
96
+ * GET /api/projects - List all projects with story counts.
97
+ */
98
+ router.get('/projects', (_req, res) => {
99
+ try {
100
+ const projects = getProjects();
101
+ res.json(projects);
102
+ } catch (err) {
103
+ res.status(500).json({ error: err.message });
104
+ }
105
+ });
106
+
107
+ /**
108
+ * GET /api/projects/:id - Project detail.
109
+ */
110
+ router.get('/projects/:id', (req, res) => {
111
+ try {
112
+ const projects = getProjects();
113
+ const project = projects.find((p) => p.id === req.params.id);
114
+ if (!project) return res.status(404).json({ error: 'Project not found' });
115
+ res.json(project);
116
+ } catch (err) {
117
+ res.status(500).json({ error: err.message });
118
+ }
119
+ });
120
+
121
+ /**
122
+ * PUT /api/projects/:id/name — PR-G: rename a project.
123
+ *
124
+ * Updates plan.name on every plan JSON belonging to the project AND
125
+ * the projects.name column directly so the UI reflects the new name
126
+ * on the next render. Body: { name: "Linux Assistant Orchestrator" }.
127
+ */
128
+ router.put('/projects/:id/name', (req, res) => {
129
+ try {
130
+ const result = renameProject({ projectId: req.params.id, name: req.body?.name });
131
+ if (!result.ok) {
132
+ const code = /no encontrado/i.test(result.error) ? 404 : 400;
133
+ return res.status(code).json({ error: result.error });
134
+ }
135
+ res.json(result);
136
+ } catch (err) {
137
+ res.status(500).json({ error: err.message });
138
+ }
139
+ });
140
+
141
+ /**
142
+ * PATCH /api/projects/:id/is-test — KJC-TSK-0371 (board polish #3).
143
+ *
144
+ * Sets/clears the per-project ephemeral-cleanup override. The
145
+ * heuristic (id under tmp_/test_/demo_/kj-test- AND last_activity
146
+ * older than 24h → auto-delete on next board start) is the default.
147
+ * `is_test` lets the user override per-project:
148
+ * - body { is_test: 1 } → always treat as ephemeral
149
+ * - body { is_test: 0 } → NEVER auto-clean (keep this one)
150
+ * - body { is_test: null } → revert to the default heuristic
151
+ *
152
+ * Sending anything else is a 400 — explicit ternary, no truthy/
153
+ * falsy coercion, because that exact distinction is what the
154
+ * cleaner relies on.
155
+ */
156
+ router.patch('/projects/:id/is-test', (req, res) => {
157
+ const raw = req.body?.is_test;
158
+ // Accept the three legal sentinel values explicitly — undefined
159
+ // (key missing) is rejected because this endpoint only exists to
160
+ // set or clear, not to "leave as-is".
161
+ const allowed = raw === 0 || raw === 1 || raw === null;
162
+ if (!allowed) {
163
+ return res.status(400).json({ error: 'is_test must be 0, 1, or null' });
164
+ }
165
+ try {
166
+ const ok = setProjectIsTest(req.params.id, raw);
167
+ if (!ok) return res.status(404).json({ error: 'project not found' });
168
+ res.json({ ok: true, project_id: req.params.id, is_test: raw });
169
+ } catch (err) {
170
+ res.status(500).json({ error: err.message });
171
+ }
172
+ });
173
+
174
+ /**
175
+ * GET /api/projects/:id/preflight — Project readiness checks.
176
+ *
177
+ * Returns the same shape regardless of the project's state. Each
178
+ * check carries a status (ok|warn|fail|info), a plain-Spanish
179
+ * detail and (when relevant) a `consequence` describing what the
180
+ * user loses if it stays unfixed. Powers both:
181
+ * - the panel at the top of the board (always visible)
182
+ * - the safety modal that appears before ▶ Run plan / ▶ Run HU
183
+ * when there are blockers or warnings
184
+ *
185
+ * The route resolves projectDir by reading the first plan it can
186
+ * find for this project (plans carry projectDir since v2.7.4). If
187
+ * the project has no plans on disk we still return the env-only
188
+ * checks (Node, Karajan, agents, config) so the user gets feedback
189
+ * even before they generate their first plan.
190
+ */
191
+ router.get('/projects/:id/preflight', async (req, res) => {
192
+ try {
193
+ const projectId = req.params.id;
194
+ let projectDir = null;
195
+ try {
196
+ // Plans live at ~/.kj/plans/<slug>/ when KJ_HOME is unset, or
197
+ // $KJ_HOME/plans/<slug>/ when set. db.js::getKjHome() returns
198
+ // the .karajan root, NOT the .kj root — they are sister dirs.
199
+ // Mirror plan-mutations.js::plansRoot() exactly.
200
+ const plansBase = process.env.KJ_HOME
201
+ ? path.join(process.env.KJ_HOME, 'plans')
202
+ : path.join(process.env.HOME || '', '.kj', 'plans');
203
+ const plansDir = path.join(plansBase, projectId);
204
+ if (fs.existsSync(plansDir)) {
205
+ const files = fs.readdirSync(plansDir).filter((f) => f.endsWith('.json'));
206
+ for (const f of files) {
207
+ try {
208
+ const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
209
+ if (plan?.projectDir) { projectDir = plan.projectDir; break; }
210
+ } catch { /* try next */ }
211
+ }
212
+ }
213
+ } catch { /* projectDir stays null → preflight will report it */ }
214
+ const result = await runPreflight({ projectId, projectDir });
215
+ res.json(result);
216
+ } catch (err) {
217
+ res.status(500).json({ error: err.message });
218
+ }
219
+ });
220
+
221
+ /**
222
+ * GET /api/projects/:id/plans-outcome — Plan-level rollups (PR3).
223
+ *
224
+ * Reads every plan JSON for the project and returns the lightweight
225
+ * shape the board needs to render the "Plan finalizado · 8 done · 2
226
+ * failed · 15 min" banner. Plans that haven't finished yet have a
227
+ * null outcome — the front handles that by hiding the banner.
228
+ */
229
+ router.get('/projects/:id/plans-outcome', (req, res) => {
230
+ try {
231
+ const projectId = req.params.id;
232
+ const plansBase = process.env.KJ_HOME
233
+ ? path.join(process.env.KJ_HOME, 'plans')
234
+ : path.join(process.env.HOME || '', '.kj', 'plans');
235
+ const plansDir = path.join(plansBase, projectId);
236
+ if (!fs.existsSync(plansDir)) return res.json({ projectId, plans: [] });
237
+ const out = [];
238
+ for (const f of fs.readdirSync(plansDir).filter(n => n.endsWith('.json'))) {
239
+ try {
240
+ const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
241
+ if (plan?.version !== 2) continue;
242
+ out.push({
243
+ planId: plan.planId,
244
+ name: plan.name || (plan.task ? plan.task.slice(0, 80) : plan.planId),
245
+ status: plan.status || 'draft',
246
+ updatedAt: plan.updatedAt || null,
247
+ outcome: plan.outcome || null,
248
+ huCount: Array.isArray(plan.hus) ? plan.hus.length : 0,
249
+ });
250
+ } catch { /* skip unreadable plan */ }
251
+ }
252
+ res.json({ projectId, plans: out });
253
+ } catch (err) {
254
+ res.status(500).json({ error: err.message });
255
+ }
256
+ });
257
+
258
+ /**
259
+ * GET /api/projects/:id/stories - Stories for a project.
260
+ */
261
+ router.get('/projects/:id/stories', (req, res) => {
262
+ try {
263
+ const stories = getStoriesByProject(req.params.id);
264
+ res.json(stories);
265
+ } catch (err) {
266
+ res.status(500).json({ error: err.message });
267
+ }
268
+ });
269
+
270
+ /**
271
+ * GET /api/stories/:id - Story detail with quality scores and context requests.
272
+ */
273
+ router.get('/stories/:id', (req, res) => {
274
+ try {
275
+ const story = getStoryDetail(req.params.id);
276
+ if (!story) return res.status(404).json({ error: 'Story not found' });
277
+ res.json(story);
278
+ } catch (err) {
279
+ res.status(500).json({ error: err.message });
280
+ }
281
+ });
282
+
283
+ /**
284
+ * GET /api/projects/:id/sessions - Sessions for a project.
285
+ */
286
+ router.get('/projects/:id/sessions', (req, res) => {
287
+ try {
288
+ const sessions = getSessionsByProject(req.params.id);
289
+ res.json(sessions);
290
+ } catch (err) {
291
+ res.status(500).json({ error: err.message });
292
+ }
293
+ });
294
+
295
+ /**
296
+ * GET /api/sessions/:id - Session detail with stages, commits, duration.
297
+ */
298
+ router.get('/sessions/:id', (req, res) => {
299
+ try {
300
+ const session = getSessionDetail(req.params.id);
301
+ if (!session) return res.status(404).json({ error: 'Session not found' });
302
+ // Parse JSON fields for the response
303
+ const parsed = { ...session };
304
+ for (const field of ['checkpoints', 'llm_calls', 'config_snapshot', 'budget', 'commits', 'stages_completed']) {
305
+ if (parsed[field] && typeof parsed[field] === 'string') {
306
+ try { parsed[field] = JSON.parse(parsed[field]); } catch { /* keep as string */ }
307
+ }
308
+ }
309
+ res.json(parsed);
310
+ } catch (err) {
311
+ res.status(500).json({ error: err.message });
312
+ }
313
+ });
314
+
315
+ /**
316
+ * GET /api/sessions - All sessions across all projects.
317
+ */
318
+ router.get('/sessions', (_req, res) => {
319
+ try {
320
+ const allProjects = getProjects();
321
+ const sessions = [];
322
+ for (const p of allProjects) {
323
+ sessions.push(...getSessionsByProject(p.id));
324
+ }
325
+ // Also get default project sessions
326
+ sessions.push(...getSessionsByProject('default'));
327
+ // Deduplicate
328
+ const seen = new Set();
329
+ const unique = sessions.filter((s) => {
330
+ if (seen.has(s.id)) return false;
331
+ seen.add(s.id);
332
+ return true;
333
+ });
334
+ unique.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
335
+ res.json(unique);
336
+ } catch (err) {
337
+ res.status(500).json({ error: err.message });
338
+ }
339
+ });
340
+
341
+ /**
342
+ * DELETE /api/projects/:id - Cascade delete project + its stories + sessions.
343
+ * Also removes the hu-stories/<id>/ directory from disk so the next sync
344
+ * does not re-import it.
345
+ */
346
+ router.delete('/projects/:id', (req, res) => {
347
+ try {
348
+ const id = req.params.id;
349
+ // Gather filesystem paths to remove BEFORE deleting DB rows so we
350
+ // can stop the next sync from resurrecting anything.
351
+ const db = getDb();
352
+ const storyIds = db.prepare('SELECT id FROM stories WHERE project_id = ?').all(id).map((r) => r.id);
353
+ const sessionIds = db.prepare('SELECT id FROM sessions WHERE project_id = ?').all(id).map((r) => r.id);
354
+ // KJC-BUG-0055: honour KJ_PLANS_DIR (the env override the sync /
355
+ // cleanup-zombies layers already respect). Previously hardcoded to
356
+ // ~/.kj/plans relative to KJ_HOME's parent, which broke when the
357
+ // two homes were configured separately and left orphan plan dirs
358
+ // on disk after a 🗑️ delete.
359
+ const plansRoot = process.env.KJ_PLANS_DIR
360
+ || path.join(os.homedir(), '.kj', 'plans');
361
+ const fsPaths = [
362
+ ...storyIds.map((sid) => path.join(huStoriesDir(), sid)),
363
+ ...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
364
+ path.join(plansRoot, id),
365
+ ];
366
+
367
+ const existed = deleteProject(id);
368
+ if (!existed) return res.status(404).json({ error: 'Project not found' });
369
+
370
+ // Tombstone the project AND its children so chokidar / fullScan
371
+ // can't bring them back via syncStoryFile / syncSessionFile.
372
+ addTombstone('project', id, { source: 'api', fsPaths });
373
+ for (const sid of storyIds) addTombstone('story', sid, { source: 'api' });
374
+ for (const sid of sessionIds) addTombstone('session', sid, { source: 'api' });
375
+
376
+ // Best-effort fs cleanup; failure does NOT undo the tombstones.
377
+ let pathsRemoved = 0;
378
+ for (const p of fsPaths) {
379
+ try { fs.rmSync(p, { recursive: true, force: true }); pathsRemoved++; } catch { /* */ }
380
+ }
381
+ res.json({ deleted: true, pathsRemoved, tombstoned: 1 + storyIds.length + sessionIds.length });
382
+ } catch (err) {
383
+ res.status(500).json({ error: err.message });
384
+ }
385
+ });
386
+
387
+ /**
388
+ * DELETE /api/prompts/:promptId - Discard a pending prompt the runner left
389
+ * behind (KJC-TSK-0380). Removes the RPC files from disk, tombstones the
390
+ * prompt id so a stale chokidar event can't recreate it, and emits a
391
+ * `prompt:resolved` event so all open boards close the modal.
392
+ */
393
+ router.delete('/prompts/:promptId', (req, res) => {
394
+ try {
395
+ const promptId = req.params.promptId;
396
+ const dir = promptsDir();
397
+ const main = path.join(dir, `${promptId}.json`);
398
+ const answer = path.join(dir, `${promptId}.answer.json`);
399
+ let removed = 0;
400
+ for (const p of [main, answer]) {
401
+ try { fs.unlinkSync(p); removed++; } catch { /* missing is fine */ }
402
+ }
403
+ addTombstone('prompt', promptId, { source: 'api', fsPaths: [main, answer] });
404
+ // Re-use the same event the runner emits when answered, so any open
405
+ // modal in any connected board client closes itself.
406
+ publishEvent({ type: 'prompt-resolved', promptId });
407
+ res.json({ deleted: true, filesRemoved: removed });
408
+ } catch (err) {
409
+ res.status(500).json({ error: err.message });
410
+ }
411
+ });
412
+
413
+ /**
414
+ * DELETE /api/stories/:id - Delete a single story.
415
+ *
416
+ * Tombstones the story (KJC-TSK-0380) and removes its batch directory
417
+ * from `~/.karajan/hu-stories/<id>/` so the next sync does NOT re-import
418
+ * it. Pre-tombstones this endpoint was a DB-only soft hide that the next
419
+ * chokidar event undid silently.
420
+ */
421
+ router.delete('/stories/:id', (req, res) => {
422
+ try {
423
+ const id = req.params.id;
424
+ const dir = path.join(huStoriesDir(), id);
425
+ const ok = deleteStory(id);
426
+ if (!ok) return res.status(404).json({ error: 'Story not found' });
427
+ addTombstone('story', id, { source: 'api', fsPaths: [dir] });
428
+ let dirRemoved = false;
429
+ try { fs.rmSync(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
430
+ res.json({ deleted: true, dirRemoved });
431
+ } catch (err) {
432
+ res.status(500).json({ error: err.message });
433
+ }
434
+ });
435
+
436
+ /**
437
+ * DELETE /api/sessions/:id - Delete a single session.
438
+ *
439
+ * Tombstones + removes `~/.karajan/sessions/<id>/` (KJC-TSK-0380).
440
+ */
441
+ router.delete('/sessions/:id', (req, res) => {
442
+ try {
443
+ const id = req.params.id;
444
+ const dir = path.join(getKjHome(), 'sessions', id);
445
+ const ok = deleteSession(id);
446
+ if (!ok) return res.status(404).json({ error: 'Session not found' });
447
+ addTombstone('session', id, { source: 'api', fsPaths: [dir] });
448
+ let dirRemoved = false;
449
+ try { fs.rmSync(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
450
+ res.json({ deleted: true, dirRemoved });
451
+ } catch (err) {
452
+ res.status(500).json({ error: err.message });
453
+ }
454
+ });
455
+
456
+ /**
457
+ * DELETE /api/plans/:planId - Delete a single plan file.
458
+ *
459
+ * Removes `~/.kj/plans/<projectSlug>/plan-<planId>.json` and tombstones
460
+ * the plan id so the next watcher event doesn't re-sync it. Does NOT
461
+ * delete the project: a project can have multiple plans, deleting one
462
+ * plan only removes that plan's HUs from the board.
463
+ */
464
+ router.delete('/plans/:planId', (req, res) => {
465
+ try {
466
+ const planId = req.params.planId;
467
+ // Locate the plan file by scanning the plans dir for filename match.
468
+ const plansRoot = process.env.KJ_PLANS_DIR || path.join(getKjHome(), '..', '.kj', 'plans');
469
+ let foundPath = null;
470
+ try {
471
+ for (const projSlug of fs.readdirSync(plansRoot)) {
472
+ const candidate = path.join(plansRoot, projSlug, `plan-${planId}.json`);
473
+ if (fs.existsSync(candidate)) { foundPath = candidate; break; }
474
+ }
475
+ } catch { /* plansRoot missing */ }
476
+ addTombstone('plan', planId, { source: 'api', fsPaths: foundPath ? [foundPath] : [] });
477
+ if (foundPath) { try { fs.unlinkSync(foundPath); } catch { /* */ } }
478
+ res.json({ deleted: true, fileRemoved: !!foundPath });
479
+ } catch (err) {
480
+ res.status(500).json({ error: err.message });
481
+ }
482
+ });
483
+
484
+ /**
485
+ * GET /api/tombstones - List every active tombstone for diagnostics.
486
+ *
487
+ * Returns rows ordered by deleted_at DESC; useful for the diagnostics
488
+ * pane and for the cleanup script (KJC-TSK-0380 PR-C).
489
+ */
490
+ router.get('/tombstones', (_req, res) => {
491
+ try {
492
+ res.json(listTombstones());
493
+ } catch (err) {
494
+ res.status(500).json({ error: err.message });
495
+ }
496
+ });
497
+
498
+ /**
499
+ * POST /api/tombstones/:type/:id/restore - Bring a buried id back to
500
+ * life. The next sync will then re-import the resource if its filesystem
501
+ * source still exists. Idempotent: returns false when nothing to remove.
502
+ */
503
+ router.post('/tombstones/:type/:id/restore', (req, res) => {
504
+ try {
505
+ const removed = removeTombstone(req.params.type, req.params.id);
506
+ if (!removed) return res.status(404).json({ error: 'Tombstone not found' });
507
+ res.json({ restored: true });
508
+ } catch (err) {
509
+ res.status(500).json({ error: err.message });
510
+ }
511
+ });
512
+
513
+ /**
514
+ * PATCH /api/stories/:id - Edit a single HU.
515
+ *
516
+ * Accepts two shapes in the same call (both optional, either or both
517
+ * can be present):
518
+ *
519
+ * { status: "pending" | "certified" | "done" | "needs_context" }
520
+ * → changes the lifecycle state (original PATCH semantics).
521
+ *
522
+ * { title?, scope?, task_type?, acceptance_criteria?,
523
+ * acceptance_tests?, blocked_by? }
524
+ * → edits the user-facing fields in place. Whitelist mirrors
525
+ * plan-hu-ops::updateHu so CLI and board stay aligned.
526
+ *
527
+ * When both are present we apply status last — the status change
528
+ * transitions the plan lifecycle and can rewrite plan.status, whereas
529
+ * field edits are local to the HU and should never fight the status
530
+ * auto-promotion.
531
+ *
532
+ * The row id on the board is `${projectId}::${huId}` — split here to
533
+ * feed the plan-mutation helpers. The source-of-truth plan JSON is
534
+ * rewritten before we ack, then re-synced into SQLite, so a reload
535
+ * renders the committed state.
536
+ */
537
+ // Status que el USUARIO puede setear manualmente vía el dropdown del
538
+ // modal. NO incluimos coding/reviewing/running — esos son lifecycle
539
+ // del orquestador (settearlos a mano genera zombies). Tampoco
540
+ // incluimos los canonicales (`running`) por la misma razón. `failed`
541
+ // y `blocked` son útiles para que el usuario marque manualmente HUs
542
+ // que no quiere correr ahora.
543
+ // KJC-TSK-0403: 'failed' eliminado — el orquestador estampa result=fail
544
+ // dejando status=pending. Setearlo a mano vía PATCH ya no tiene sentido.
545
+ const ALLOWED_STORY_STATUSES = new Set(['pending', 'certified', 'done', 'blocked', 'needs_context']);
546
+ // KJC-TSK-0406: coder_model y reviewer_model son editables desde el
547
+ // modal del board. Independientes — el usuario puede subir el reviewer
548
+ // y bajar el coder sin afectar al resto del plan.
549
+ const EDITABLE_HU_FIELDS = ['title', 'scope', 'task_type', 'acceptance_criteria', 'acceptance_tests', 'blocked_by', 'coder_model', 'reviewer_model', 'coder_provider', 'reviewer_provider'];
550
+
551
+ router.patch('/stories/:id', (req, res) => {
552
+ try {
553
+ const body = req.body || {};
554
+ const hasStatus = Object.prototype.hasOwnProperty.call(body, 'status');
555
+ const fieldPatch = {};
556
+ for (const k of EDITABLE_HU_FIELDS) {
557
+ if (Object.prototype.hasOwnProperty.call(body, k)) fieldPatch[k] = body[k];
558
+ }
559
+ const hasFieldEdit = Object.keys(fieldPatch).length > 0;
560
+
561
+ if (!hasStatus && !hasFieldEdit) {
562
+ return res.status(400).json({
563
+ error: `Body must include status or one of: ${EDITABLE_HU_FIELDS.join(', ')}`,
564
+ });
565
+ }
566
+ if (hasStatus && !ALLOWED_STORY_STATUSES.has(body.status)) {
567
+ return res.status(400).json({
568
+ error: `status must be one of: ${[...ALLOWED_STORY_STATUSES].join(', ')}`,
569
+ });
570
+ }
571
+
572
+ const row = getStoryRow(req.params.id);
573
+ if (!row) return res.status(404).json({ error: 'Story not found' });
574
+ if (!row.plan_id) {
575
+ return res.status(409).json({
576
+ error: 'Story is not backed by a plan file (legacy row). Re-run `kj plan` to re-import it.',
577
+ });
578
+ }
579
+ const huId = req.params.id.includes('::')
580
+ ? req.params.id.split('::').slice(1).join('::')
581
+ : req.params.id;
582
+
583
+ let updatedHu;
584
+ if (hasFieldEdit) {
585
+ const fieldResult = setHuFields({
586
+ planId: row.plan_id,
587
+ huId,
588
+ patch: fieldPatch,
589
+ projectId: row.project_id,
590
+ });
591
+ if (!fieldResult.ok) {
592
+ // KJC-TSK-0401: validación de blocked_by devuelve 400 con cycle?
593
+ // info para que el board destaque la cadena en la UI. Resto de
594
+ // fallos siguen devolviendo 404 (plan / hu no encontrados).
595
+ if (fieldResult.code === 'INVALID_BLOCKED_BY') {
596
+ return res.status(400).json({ error: fieldResult.error, cycle: fieldResult.cycle });
597
+ }
598
+ return res.status(404).json({ error: fieldResult.error });
599
+ }
600
+ updatedHu = fieldResult.hu;
601
+ }
602
+
603
+ let statusResult;
604
+ if (hasStatus) {
605
+ statusResult = setHuStatus({
606
+ planId: row.plan_id,
607
+ huId,
608
+ status: body.status,
609
+ projectId: row.project_id,
610
+ });
611
+ if (!statusResult.ok) return res.status(404).json({ error: statusResult.error });
612
+ }
613
+
614
+ res.json({
615
+ updated: true,
616
+ id: req.params.id,
617
+ status: statusResult ? statusResult.status : undefined,
618
+ planStatus: statusResult ? statusResult.planStatus : undefined,
619
+ hu: updatedHu,
620
+ });
621
+ } catch (err) {
622
+ res.status(500).json({ error: err.message });
623
+ }
624
+ });
625
+
626
+ /**
627
+ * POST /api/stories/:id/undo — KJC-TSK-0408: revert a HU.
628
+ *
629
+ * Restaura los ficheros del workspace al snapshot pre-run guardado por
630
+ * la pipeline. Marca status=pending + result=null + outcome.reverted=true
631
+ * para que el usuario pueda relanzar la HU con otros modelos.
632
+ *
633
+ * Falla con 400 si la HU no tiene snapshot (HUs anteriores al hook).
634
+ * Falla con 404 si plan/HU no existe.
635
+ */
636
+ router.post('/stories/:id/undo', async (req, res) => {
637
+ try {
638
+ const row = getStoryRow(req.params.id);
639
+ if (!row) return res.status(404).json({ error: 'Story not found' });
640
+ if (!row.plan_id) {
641
+ return res.status(409).json({ error: 'Story sin plan_id — legacy row no soportada.' });
642
+ }
643
+ const huId = req.params.id.includes('::')
644
+ ? req.params.id.split('::').slice(1).join('::')
645
+ : req.params.id;
646
+ const result = await revertHuFromSnapshot({ planId: row.plan_id, huId, projectId: row.project_id });
647
+ if (!result.ok) {
648
+ const code = /not found|no encontrado/i.test(result.error) ? 404 : 400;
649
+ return res.status(code).json({ error: result.error });
650
+ }
651
+ res.json({ ok: true, id: req.params.id, sha: result.sha });
652
+ } catch (err) {
653
+ res.status(500).json({ error: err.message });
654
+ }
655
+ });
656
+
657
+ /**
658
+ * POST /api/plans/:planId/ready - Bulk-certify every pending HU of a plan.
659
+ * Equivalent to the CLI `kj plan ready <planId>`. Body is optional — if the
660
+ * client knows the projectId we use it as a fast path, otherwise we fall
661
+ * back to scanning the plans dir.
662
+ */
663
+ router.post('/plans/:planId/ready', (req, res) => {
664
+ try {
665
+ const { projectId } = req.body || {};
666
+ const result = markPlanReady({ planId: req.params.planId, projectId });
667
+ if (!result.ok) return res.status(404).json({ error: result.error });
668
+ res.json({ ready: true, planId: req.params.planId, count: result.count, planStatus: result.planStatus });
669
+ } catch (err) {
670
+ res.status(500).json({ error: err.message });
671
+ }
672
+ });
673
+
674
+ /**
675
+ * POST /api/projects/:id/ready - Sugar: mark every plan of a project ready
676
+ * in one call. Lets the board expose a single "Mark all certified" button
677
+ * per project view without the UI having to enumerate plan ids first.
678
+ */
679
+ router.post('/projects/:id/ready', (req, res) => {
680
+ try {
681
+ const planIds = listPlanIdsForProject(req.params.id);
682
+ if (planIds.length === 0) {
683
+ return res.status(404).json({ error: 'No plan-backed stories for this project' });
684
+ }
685
+ const results = [];
686
+ for (const planId of planIds) {
687
+ const r = markPlanReady({ planId, projectId: req.params.id });
688
+ results.push({ planId, ...r });
689
+ }
690
+ const totalCertified = results
691
+ .filter((r) => r.ok)
692
+ .reduce((acc, r) => acc + r.count, 0);
693
+ res.json({
694
+ ready: true,
695
+ projectId: req.params.id,
696
+ plans: results,
697
+ totalCertified,
698
+ });
699
+ } catch (err) {
700
+ res.status(500).json({ error: err.message });
701
+ }
702
+ });
703
+
704
+ /**
705
+ * Resolve the prompts directory the runner writes RPC packets to.
706
+ * Same convention as src/utils/board-prompt-bridge.js on the runner.
707
+ */
708
+ function promptsDir() {
709
+ return path.join(getKjHome(), 'prompts');
710
+ }
711
+
712
+ /**
713
+ * GET /api/prompts - List currently pending prompts. The runner is
714
+ * blocked on each one waiting for an answer; the board reads this on
715
+ * boot to surface modals for prompts that were already pending when
716
+ * the page loaded (the SSE event fires only on new arrivals).
717
+ */
718
+ // KJC-BUG-0038: prompts whose runner crashed leave the .json behind in
719
+ // ~/.kj/prompts/. Without a TTL, every board reload re-shows the zombie
720
+ // modal "Karajan needs an answer" with no runner behind it — the user
721
+ // can answer but nothing happens. 30 minutes covers the longest legitimate
722
+ // Solomon escalation; anything older is a crashed runner.
723
+ const PROMPT_ZOMBIE_TTL_MS = 30 * 60 * 1000;
724
+
725
+ router.get('/prompts', (_req, res) => {
726
+ try {
727
+ const dir = promptsDir();
728
+ if (!fs.existsSync(dir)) return res.json([]);
729
+ const entries = fs.readdirSync(dir);
730
+ const result = [];
731
+ const now = Date.now();
732
+ for (const name of entries) {
733
+ if (!name.endsWith('.json') || name.endsWith('.answer.json')) continue;
734
+ const promptId = name.replace(/\.json$/, '');
735
+ const mainPath = path.join(dir, name);
736
+ const answerPath = path.join(dir, `${promptId}.answer.json`);
737
+ // KJC-TSK-0380 — skip tombstoned prompts and clean their files.
738
+ if (isTombstonedFn('prompt', promptId)) {
739
+ try { fs.unlinkSync(mainPath); } catch { /* */ }
740
+ try { fs.unlinkSync(answerPath); } catch { /* */ }
741
+ continue;
742
+ }
743
+ let parsed;
744
+ try {
745
+ parsed = JSON.parse(fs.readFileSync(mainPath, 'utf-8'));
746
+ } catch { continue; /* malformed — skip */ }
747
+ // KJC-BUG-0038: zombie detection. Prefer parsed.createdAt; fall back
748
+ // to mtime if the runner skipped the timestamp (older formats).
749
+ const createdMs = Date.parse(parsed?.createdAt || '');
750
+ const ageRef = Number.isFinite(createdMs) ? createdMs : safeMtime(mainPath);
751
+ if (ageRef && (now - ageRef) > PROMPT_ZOMBIE_TTL_MS) {
752
+ try { fs.unlinkSync(mainPath); } catch { /* */ }
753
+ try { fs.unlinkSync(answerPath); } catch { /* */ }
754
+ addTombstone('prompt', promptId, { source: 'zombie-ttl', fsPaths: [mainPath, answerPath] });
755
+ continue;
756
+ }
757
+ result.push(parsed);
758
+ }
759
+ result.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
760
+ res.json(result);
761
+ } catch (err) {
762
+ res.status(500).json({ error: err.message });
763
+ }
764
+ });
765
+
766
+ function safeMtime(p) {
767
+ try { return fs.statSync(p).mtimeMs; } catch { return 0; }
768
+ }
769
+
770
+ /**
771
+ * POST /api/prompts/:promptId/answer - Body { answer }. Writes a
772
+ * sibling .answer.json the runner is polling for. The runner reads
773
+ * it, deletes both files, and resolves askQuestion. An answer of
774
+ * "stop" tells the runner to abort the session.
775
+ */
776
+ router.post('/prompts/:promptId/answer', (req, res) => {
777
+ try {
778
+ const { answer } = req.body || {};
779
+ if (typeof answer !== 'string') {
780
+ return res.status(400).json({ error: 'Body must contain an "answer" string' });
781
+ }
782
+ const dir = promptsDir();
783
+ fs.mkdirSync(dir, { recursive: true });
784
+ const promptPath = path.join(dir, `${req.params.promptId}.json`);
785
+ if (!fs.existsSync(promptPath)) {
786
+ return res.status(404).json({ error: `Prompt not found: ${req.params.promptId}` });
787
+ }
788
+ const answerPath = path.join(dir, `${req.params.promptId}.answer.json`);
789
+ fs.writeFileSync(
790
+ answerPath,
791
+ JSON.stringify({ answer, answeredAt: new Date().toISOString() }, null, 2),
792
+ 'utf-8'
793
+ );
794
+ res.json({ ok: true });
795
+ } catch (err) {
796
+ res.status(500).json({ error: err.message });
797
+ }
798
+ });
799
+
800
+ /**
801
+ * GET /api/events - Server-Sent Events stream for live board updates.
802
+ *
803
+ * Replaces the old "sync → full page re-render" dance. The chokidar
804
+ * watcher publishes a small event on every sync (plan/session/batch);
805
+ * this endpoint pipes those events to every connected client. The
806
+ * client patches the affected card in place, so scroll position and
807
+ * any open modal survive the update.
808
+ *
809
+ * The connection stays open for the life of the tab. Browsers
810
+ * auto-reconnect on socket drop — we send a keep-alive comment every
811
+ * 25s so intermediate proxies don't kill the idle socket.
812
+ */
813
+ router.get('/events', (req, res) => {
814
+ res.writeHead(200, {
815
+ 'Content-Type': 'text/event-stream',
816
+ 'Cache-Control': 'no-cache, no-transform',
817
+ Connection: 'keep-alive',
818
+ // Disable Express compression on this route — SSE must be a raw
819
+ // stream, gzip buffers and breaks the "push on every event" model.
820
+ 'X-Accel-Buffering': 'no',
821
+ });
822
+ res.flushHeaders?.();
823
+ // Initial hello so the client knows the stream is open. Useful for
824
+ // the integration test too: the EventSource-equivalent supertest
825
+ // client can wait on the first line.
826
+ res.write('retry: 2000\n');
827
+ res.write('event: hello\ndata: {"ok":true}\n\n');
828
+
829
+ const send = (event) => {
830
+ try {
831
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
832
+ } catch {
833
+ // Client gone — the close handler will clean up shortly.
834
+ }
835
+ };
836
+ const unsubscribe = subscribeEvents(send);
837
+
838
+ // 25s is well under the default 60-90s idle timeout of every sane
839
+ // reverse proxy, but high enough that it doesn't spam the log.
840
+ const keepAlive = setInterval(() => {
841
+ try { res.write(': keep-alive\n\n'); } catch { /* ignore */ }
842
+ }, 25000);
843
+
844
+ req.on('close', () => {
845
+ clearInterval(keepAlive);
846
+ unsubscribe();
847
+ res.end?.();
848
+ });
849
+ });
850
+
851
+ /**
852
+ * GET /api/plans/:planId/log - Tail the detached run's log file.
853
+ *
854
+ * Accepts ?offset=N so a polling UI can request only the new bytes
855
+ * since last read (the "tail -f" UX without SSE). Returns:
856
+ *
857
+ * { content: string, size: number, exists: boolean }
858
+ *
859
+ * - `exists` is false when the run hasn't started yet (the log path is
860
+ * deterministic, so we know where to look).
861
+ * - `size` is the current file length; clients pass `offset=size` next
862
+ * time to get only the delta.
863
+ *
864
+ * Path is the same one that `runPlan` wrote to:
865
+ * ~/.karajan/hu-board-runs/<planId>.log (honours KJ_HOME in tests).
866
+ */
867
+ router.get('/plans/:planId/log', (req, res) => {
868
+ try {
869
+ const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
870
+ const logPath = path.join(runsDir, `${req.params.planId}.log`);
871
+ if (!fs.existsSync(logPath)) {
872
+ return res.json({ exists: false, size: 0, content: '' });
873
+ }
874
+ const stat = fs.statSync(logPath);
875
+ const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
876
+ // Cap per-request read to 1 MiB so a huge log can't blow up the
877
+ // response. The client will keep polling and catch up.
878
+ const MAX_READ = 1024 * 1024;
879
+ const length = Math.min(MAX_READ, stat.size - offset);
880
+ let content = '';
881
+ if (length > 0) {
882
+ const fd = fs.openSync(logPath, 'r');
883
+ try {
884
+ const buf = Buffer.alloc(length);
885
+ fs.readSync(fd, buf, 0, length, offset);
886
+ content = buf.toString('utf-8');
887
+ } finally {
888
+ fs.closeSync(fd);
889
+ }
890
+ }
891
+ res.json({ exists: true, size: stat.size, content });
892
+ } catch (err) {
893
+ res.status(500).json({ error: err.message });
894
+ }
895
+ });
896
+
897
+ /**
898
+ * POST /api/plans/:planId/run - Launch `kj run --plan <planId>` as a
899
+ * detached child. This is the "Run plan" button's endpoint — the board
900
+ * no longer requires the user to drop to a terminal to kick off the
901
+ * pipeline.
902
+ *
903
+ * Body: { projectId?: string, task?: string } — both optional. projectId
904
+ * makes the plan-file lookup O(1); task overrides plan.task as the
905
+ * pipeline's headline description.
906
+ *
907
+ * Returns the child's pid + the log path so a future "tail this run"
908
+ * viewer can attach.
909
+ */
910
+ router.post('/plans/:planId/run', (req, res) => {
911
+ try {
912
+ const { projectId, task, huIds } = req.body || {};
913
+ // PR4: callers may pass `huIds: ["hu_x_001"]` to scope the run
914
+ // to a single HU (or comma-separated subset). When omitted, the
915
+ // whole plan runs as before.
916
+ const result = runPlan({
917
+ planId: req.params.planId,
918
+ projectId,
919
+ taskOverride: task,
920
+ huIds: Array.isArray(huIds) ? huIds : null,
921
+ });
922
+ if (!result.ok) {
923
+ // 404 for "plan not found" (preserves the contract the existing
924
+ // test asserts on), 400 for validation errors (unknown HU id,
925
+ // missing projectDir, etc.).
926
+ const code = /^plan not found/.test(result.error) ? 404 : 400;
927
+ return res.status(code).json({ error: result.error });
928
+ }
929
+ res.json({
930
+ launched: true,
931
+ planId: req.params.planId,
932
+ pid: result.pid,
933
+ logPath: result.logPath,
934
+ huIds: Array.isArray(huIds) && huIds.length > 0 ? huIds : null,
935
+ });
936
+ } catch (err) {
937
+ res.status(500).json({ error: err.message });
938
+ }
939
+ });
940
+
941
+ /**
942
+ * POST /api/hus/:huId/run — PR4: launch a single HU (or, when the
943
+ * caller passes additional ids in body.extraHuIds, a small bundle).
944
+ *
945
+ * The HU's plan is resolved from the SQLite stories table so the
946
+ * front doesn't have to know the planId — it only needs the story
947
+ * id from the card.
948
+ */
949
+ router.post('/hus/:huId/run', (req, res) => {
950
+ try {
951
+ const huId = req.params.huId;
952
+ const row = getStoryRow(huId);
953
+ if (!row) return res.status(404).json({ error: `unknown HU: ${huId}` });
954
+ if (!row.plan_id) return res.status(400).json({ error: `HU ${huId} is not backed by a plan (legacy row)` });
955
+
956
+ // Story id on disk uses just the HU local id (hu_<plan>_NNN),
957
+ // not the "<projectId>::<huId>" composite the board persists.
958
+ // Strip the prefix back off before forwarding to runPlan.
959
+ const planLocalHuId = huId.includes('::') ? huId.split('::').pop() : huId;
960
+ const extraIds = Array.isArray(req.body?.extraHuIds) ? req.body.extraHuIds.map((x) => (x.includes('::') ? x.split('::').pop() : x)) : [];
961
+ const huIds = [planLocalHuId, ...extraIds];
962
+
963
+ const result = runPlan({ planId: row.plan_id, projectId: row.project_id, huIds });
964
+ if (!result.ok) return res.status(400).json({ error: result.error });
965
+
966
+ // Move the launched HU(s) to "coding" in BOTH the SQLite mirror and the
967
+ // source-of-truth plan JSON. The plan JSON write is what makes it stick
968
+ // — the next /api/sync re-reads the plan file and would otherwise
969
+ // overwrite the SQLite update back to "certified". Without this the
970
+ // card flashes to Running for ~1s and snaps back, which the user reads
971
+ // as "nothing happened".
972
+ //
973
+ // The orchestrator owns the lifecycle once it boots (it'll move the HU
974
+ // through coding → reviewing → done/failed and persist back to the plan
975
+ // JSON), so this is just the bootstrap nudge to give the user immediate
976
+ // visual feedback.
977
+ const allHuIds = [planLocalHuId, ...extraIds];
978
+ for (const localId of allHuIds) {
979
+ try { setHuStatus({ planId: row.plan_id, huId: localId, status: 'coding', projectId: row.project_id }); } catch { /* non-fatal */ }
980
+ // Also stamp the SQLite row so the next renderBoard() (before any
981
+ // sync tick) shows it in Running.
982
+ const compositeId = localId.includes('::') ? localId : `${row.project_id}::${localId}`;
983
+ try { updateStoryStatus(compositeId, 'coding'); } catch { /* non-fatal */ }
984
+ }
985
+
986
+ res.json({
987
+ launched: true,
988
+ planId: row.plan_id,
989
+ huIds,
990
+ pid: result.pid,
991
+ logPath: result.logPath,
992
+ });
993
+ } catch (err) {
994
+ res.status(500).json({ error: err.message });
995
+ }
996
+ });
997
+
998
+ /**
999
+ * POST /api/projects/:id/run - Sugar: launch every plan of a project. For
1000
+ * the common case where each project has exactly one active plan this
1001
+ * behaves the same as POST /plans/:planId/run; with multiple plans it
1002
+ * kicks each one in its own detached child.
1003
+ */
1004
+ router.post('/projects/:id/run', (req, res) => {
1005
+ try {
1006
+ const planIds = listPlanIdsForProject(req.params.id);
1007
+ if (planIds.length === 0) {
1008
+ return res.status(404).json({ error: 'No plan-backed stories for this project' });
1009
+ }
1010
+ const results = [];
1011
+ for (const planId of planIds) {
1012
+ const r = runPlan({ planId, projectId: req.params.id });
1013
+ results.push({ planId, ...r });
1014
+ }
1015
+ const launched = results.filter((r) => r.ok).length;
1016
+ res.json({ launched, total: planIds.length, results });
1017
+ } catch (err) {
1018
+ res.status(500).json({ error: err.message });
1019
+ }
1020
+ });
1021
+
1022
+ /**
1023
+ * GET /api/config — read the editable subset of ~/.karajan/kj.config.yml.
1024
+ * Powers the board's settings modal; non-editable keys in the yml are
1025
+ * left untouched on PUT (see writeConfigPatch's whitelist semantics).
1026
+ */
1027
+ router.get('/config', (_req, res) => {
1028
+ try {
1029
+ const data = readConfig();
1030
+ res.json(data);
1031
+ } catch (err) {
1032
+ res.status(500).json({ error: err.message });
1033
+ }
1034
+ });
1035
+
1036
+ /**
1037
+ * PUT /api/config — apply a patch to the yml. Body is `{ patch: { key: value } }`
1038
+ * with the keys defined in EDITABLE_FIELDS. Unknown keys are 400'd; type
1039
+ * errors are 400'd; validation errors are 400'd. Writes are atomic
1040
+ * (tmp + rename) and a `.bak` is kept of the previous content.
1041
+ */
1042
+ router.put('/config', (req, res) => {
1043
+ try {
1044
+ const patch = req.body?.patch;
1045
+ const result = writeConfigPatch(patch);
1046
+ if (!result.written) {
1047
+ return res.status(400).json({ error: 'No se pudo guardar la configuración', details: result });
1048
+ }
1049
+ res.json(result);
1050
+ } catch (err) {
1051
+ res.status(500).json({ error: err.message });
1052
+ }
1053
+ });
1054
+
1055
+ /**
1056
+ * GET /api/commands - List the kj commands the board can launch.
1057
+ * The UI uses this to render the Actions panel without hardcoding
1058
+ * the command list on the client.
1059
+ */
1060
+ router.get('/commands', (_req, res) => {
1061
+ res.json({ commands: listSupportedCommands() });
1062
+ });
1063
+
1064
+ /**
1065
+ * POST /api/commands/:command - Spawn a kj <command> as a detached
1066
+ * child. Body fields depend on the command (see command-runner.js
1067
+ * for the per-command input schema). Returns a commandId the client
1068
+ * can pass to GET /api/runs/:commandId/log to tail the output.
1069
+ *
1070
+ * The board never executes free-form arg arrays — the command name
1071
+ * must be in the whitelist and each command builds its argv from a
1072
+ * typed schema. Anything outside that fails 400.
1073
+ */
1074
+ router.post('/commands/:command', (req, res) => {
1075
+ try {
1076
+ const result = runKjCommand({ command: req.params.command, input: req.body || {} });
1077
+ if (!result.ok) return res.status(400).json({ error: result.error });
1078
+ res.json({
1079
+ ok: true,
1080
+ commandId: result.commandId,
1081
+ pid: result.pid,
1082
+ logPath: result.logPath,
1083
+ });
1084
+ } catch (err) {
1085
+ res.status(500).json({ error: err.message });
1086
+ }
1087
+ });
1088
+
1089
+ /**
1090
+ * GET /api/runs/:commandId/log - Generalisation of the per-plan log
1091
+ * tail. The original `/api/plans/:planId/log` reads
1092
+ * `<runsDir>/<planId>.log`; this one reads `<runsDir>/<commandId>.log`
1093
+ * where `commandId` is what `/api/commands/:command` returned.
1094
+ *
1095
+ * Same offset semantics + 1 MiB cap as the plans endpoint.
1096
+ */
1097
+ /**
1098
+ * GET /api/runs/:planId/active - Devuelve los runs activos (PIDs vivos)
1099
+ * de un plan. Usado por el frontend para decidir si mostrar el botón
1100
+ * ⏹ Stop. Respuesta: { active: [{pid, huIds, startedAt}, ...] }.
1101
+ *
1102
+ * KJC-TSK-0396.
1103
+ */
1104
+ router.get('/runs/:planId/active', (req, res) => {
1105
+ try {
1106
+ const active = getActiveRuns(req.params.planId);
1107
+ res.json({ active });
1108
+ } catch (err) {
1109
+ res.status(500).json({ error: err.message });
1110
+ }
1111
+ });
1112
+
1113
+ /**
1114
+ * POST /api/runs/:planId/stop - Mata todos los procesos del orquestador
1115
+ * asociados a este plan. SIGTERM primero, espera 5s, escala a SIGKILL
1116
+ * si el proceso no respondió. Devuelve { stopped, killed, errors }.
1117
+ *
1118
+ * Tras matar el proceso, las HUs que estaban en coding/reviewing se
1119
+ * resetean a pending (no a failed) vía hu-zombie-reaper logic — pero
1120
+ * eso lo hace el reaper en el siguiente arranque del board. Para
1121
+ * efecto inmediato, marcamos aquí las HUs running del plan a pending.
1122
+ *
1123
+ * KJC-TSK-0396.
1124
+ */
1125
+ router.post('/runs/:planId/stop', async (req, res) => {
1126
+ const { planId } = req.params;
1127
+ const stopTimeoutMs = Number(req.body?.timeoutMs ?? 5000);
1128
+ try {
1129
+ const errors = [];
1130
+ let stopped = 0;
1131
+ let killed = 0;
1132
+ const active = getActiveRuns(planId);
1133
+ // Fase 1: SIGTERM a todos los procesos trackeados (si los hay).
1134
+ for (const run of active) {
1135
+ try { process.kill(run.pid, 'SIGTERM'); stopped += 1; }
1136
+ catch (err) { errors.push({ pid: run.pid, phase: 'SIGTERM', error: err.message }); }
1137
+ }
1138
+ // Fase 2: esperar `stopTimeoutMs` y escalar a SIGKILL los que sigan
1139
+ // vivos. Saltamos la espera si no había nada que matar.
1140
+ if (active.length > 0) {
1141
+ await new Promise((resolve) => setTimeout(resolve, stopTimeoutMs));
1142
+ const stillAlive = getActiveRuns(planId);
1143
+ for (const run of stillAlive) {
1144
+ try { process.kill(run.pid, 'SIGKILL'); killed += 1; }
1145
+ catch (err) { errors.push({ pid: run.pid, phase: 'SIGKILL', error: err.message }); }
1146
+ }
1147
+ }
1148
+ // Fase 3: SIEMPRE reset de HUs en coding/reviewing del plan → pending.
1149
+ // El reset corre aunque no hubiera PIDs trackeados (caso: el run lo
1150
+ // lanzaste manualmente desde la terminal, killeaste tú mismo el
1151
+ // proceso, pero la DB sigue mostrando HUs en coding y necesitas
1152
+ // dejarlas en pending para relanzar limpio).
1153
+ let hu_reset_count = 0;
1154
+ try {
1155
+ const db = getDb();
1156
+ const result = db.prepare(
1157
+ "UPDATE stories SET status = 'pending', updated_at = ? WHERE plan_id = ? AND status IN ('coding', 'reviewing', 'running')"
1158
+ ).run(new Date().toISOString(), planId);
1159
+ hu_reset_count = result.changes;
1160
+ } catch (err) {
1161
+ errors.push({ phase: 'hu_reset', error: err.message });
1162
+ }
1163
+ const out = { stopped, killed, errors, hu_reset_count };
1164
+ if (stopped === 0 && killed === 0 && hu_reset_count === 0) {
1165
+ out.message = 'No active runs and no stuck HUs for this plan.';
1166
+ }
1167
+ res.json(out);
1168
+ } catch (err) {
1169
+ res.status(500).json({ error: err.message });
1170
+ }
1171
+ });
1172
+
1173
+ router.get('/runs/:commandId/log', (req, res) => {
1174
+ try {
1175
+ const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
1176
+ const logPath = path.join(runsDir, `${req.params.commandId}.log`);
1177
+ if (!fs.existsSync(logPath)) {
1178
+ return res.json({ exists: false, size: 0, content: '' });
1179
+ }
1180
+ const stat = fs.statSync(logPath);
1181
+ const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
1182
+ const MAX_READ = 1024 * 1024;
1183
+ const length = Math.min(MAX_READ, stat.size - offset);
1184
+ let content = '';
1185
+ if (length > 0) {
1186
+ const fd = fs.openSync(logPath, 'r');
1187
+ try {
1188
+ const buf = Buffer.alloc(length);
1189
+ fs.readSync(fd, buf, 0, length, offset);
1190
+ content = buf.toString('utf-8');
1191
+ } finally {
1192
+ fs.closeSync(fd);
1193
+ }
1194
+ }
1195
+ res.json({ exists: true, size: stat.size, content });
1196
+ } catch (err) {
1197
+ res.status(500).json({ error: err.message });
1198
+ }
1199
+ });
1200
+
1201
+ /**
1202
+ * POST /api/board/restart - Respawn the board server in place. The
1203
+ * server can't truly restart itself (it'd kill its own process), so
1204
+ * we use the detached-child + parent-exit pattern: spawn a copy with
1205
+ * the same argv + cwd + env, unref it, ack the client, then exit
1206
+ * after a 250 ms delay so the response actually flushes.
1207
+ *
1208
+ * The new server overwrites the PID file and bindes the same port
1209
+ * (or the next free one if the old one hasn't released yet — handled
1210
+ * by the existing find-available-port logic in startBoard).
1211
+ *
1212
+ * The browser tab's EventSource auto-reconnects, so the user sees a
1213
+ * brief blip and the board comes back live.
1214
+ */
1215
+ /**
1216
+ * POST /api/board/shutdown - Tell the server to exit cleanly. Used
1217
+ * by `kj board stop` as a fallback when the PID file is missing or
1218
+ * stale (typical after the user has been hot-pulling Karajan and
1219
+ * the running server is from a build that didn't write the PID
1220
+ * file). The body of the response is sent before we exit, then a
1221
+ * 250 ms delay to let the response flush, then process.exit(0).
1222
+ */
1223
+ router.post('/board/shutdown', (_req, res) => {
1224
+ if (process.env.KJ_BOARD_RESTART_MODE === 'echo') {
1225
+ return res.json({ ok: true, mode: 'echo' });
1226
+ }
1227
+ res.json({ ok: true, shuttingDownInMs: 250 });
1228
+ setTimeout(() => process.exit(0), 250);
1229
+ });
1230
+
1231
+ router.post('/board/restart', (_req, res) => {
1232
+ // Tests / programmatic callers can short-circuit the actual self-
1233
+ // exec via env so the suite doesn't have its node process killed
1234
+ // mid-test by a misfire.
1235
+ if (process.env.KJ_BOARD_RESTART_MODE === 'echo') {
1236
+ return res.json({ ok: true, restartingInMs: 0, mode: 'echo' });
1237
+ }
1238
+ try {
1239
+ res.json({ ok: true, restartingInMs: 250 });
1240
+ setTimeout(() => {
1241
+ try {
1242
+ const child = spawnChild(
1243
+ process.execPath,
1244
+ process.argv.slice(1),
1245
+ { cwd: process.cwd(), detached: true, stdio: 'ignore', env: process.env }
1246
+ );
1247
+ child.unref();
1248
+ } catch { /* if respawn fails, the user can `kj board start` manually */ }
1249
+ process.exit(0);
1250
+ }, 250);
1251
+ } catch (err) {
1252
+ res.status(500).json({ error: err.message });
1253
+ }
1254
+ });
1255
+
1256
+ /**
1257
+ * POST /api/sync - Force a full re-scan of hu-stories/ and sessions/ on disk.
1258
+ * Useful when chokidar's file watcher misses new subdirectories created after
1259
+ * the board started (known limitation with glob patterns).
1260
+ */
1261
+ router.post('/sync', (_req, res) => {
1262
+ try {
1263
+ fullScan();
1264
+ const projects = getProjects();
1265
+ res.json({ synced: true, projects: projects.length });
1266
+ } catch (err) {
1267
+ res.status(500).json({ error: err.message });
1268
+ }
1269
+ });
1270
+
1271
+ export default router;