great-cto 2.77.0 → 2.77.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 (41) hide show
  1. package/board/.claude-plugin/plugin.json +256 -0
  2. package/board/packages/board/lib/alerts.mjs +413 -0
  3. package/board/packages/board/lib/beads.mjs +241 -0
  4. package/board/packages/board/lib/config.mjs +49 -0
  5. package/board/packages/board/lib/data-readers.mjs +340 -0
  6. package/board/packages/board/lib/fleet.mjs +363 -0
  7. package/board/packages/board/lib/metrics.mjs +282 -0
  8. package/board/packages/board/lib/notifications.mjs +50 -0
  9. package/board/packages/board/lib/projects.mjs +307 -0
  10. package/board/packages/board/lib/routes.mjs +1036 -0
  11. package/board/packages/board/lib/share.mjs +143 -0
  12. package/board/packages/board/lib/sse.mjs +20 -0
  13. package/board/packages/board/lib/state.mjs +31 -0
  14. package/board/packages/board/lib/util.mjs +36 -0
  15. package/board/packages/board/lib/verdicts.mjs +168 -0
  16. package/board/packages/board/lib/watchers.mjs +116 -0
  17. package/board/packages/board/mcp-server.mjs +310 -0
  18. package/board/packages/board/public/assets/apple-touch-icon.png +0 -0
  19. package/board/packages/board/public/assets/favicon-16.png +0 -0
  20. package/board/packages/board/public/assets/favicon-32.png +0 -0
  21. package/board/packages/board/public/assets/favicon.ico +0 -0
  22. package/board/packages/board/public/assets/favicon.svg +8 -0
  23. package/board/packages/board/public/assets/fonts/PROVENANCE.md +21 -0
  24. package/board/packages/board/public/assets/fonts/fonts.css +26 -0
  25. package/board/packages/board/public/assets/fonts/geist-mono-variable.woff2 +0 -0
  26. package/board/packages/board/public/assets/fonts/geist-variable.woff2 +0 -0
  27. package/board/packages/board/public/assets/vendor/PROVENANCE.md +21 -0
  28. package/board/packages/board/public/assets/vendor/marked.min.js +6 -0
  29. package/board/packages/board/public/assets/vendor/purify.min.js +3 -0
  30. package/board/packages/board/public/index.html +5464 -0
  31. package/board/packages/board/public/share.html +1194 -0
  32. package/board/packages/board/public/sw.js +79 -0
  33. package/board/packages/board/push-adapter.mjs +222 -0
  34. package/board/packages/board/server.mjs +133 -0
  35. package/board/packages/cli/dist/archetypes.js +1461 -0
  36. package/board/scripts/lib/change-tier.mjs +119 -0
  37. package/board/scripts/lib/gate-plan.mjs +119 -0
  38. package/board/scripts/lib/judge-model.mjs +42 -0
  39. package/dist/board-path.js +47 -0
  40. package/dist/main.js +3 -31
  41. package/package.json +3 -1
@@ -0,0 +1,1036 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import {
5
+ getVapidKeys,
6
+ addSubscription,
7
+ removeSubscription,
8
+ } from '../push-adapter.mjs';
9
+ import { PORT, GREAT_CTO_DIR, VAPID_KEYS_FILE, PUSH_SUBS_FILE, BUILD_VERSION } from './config.mjs';
10
+ import { eventSurface, readFileSafe } from './util.mjs';
11
+ import { sseClients, notifHistory } from './state.mjs';
12
+ import { autoRegisterProject, listProjects, resolveProjectCwd, getChangeTier } from './projects.mjs';
13
+ import { broadcastTasks } from './sse.mjs';
14
+ import { saveNotifHistory } from './notifications.mjs';
15
+ import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
16
+ import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks } from './beads.mjs';
17
+ import { getMetrics } from './metrics.mjs';
18
+ import { readVerdicts } from './verdicts.mjs';
19
+ import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
20
+ import { getResume, getShareState, toggleShare } from './share.mjs';
21
+
22
+ // ── HTTP router ────────────────────────────────────────────────────────────────
23
+ // dispatch(req, res, url, cwd, projInfo) handles every /api/* route plus /api/sse.
24
+ // Returns true if the request was handled (response already sent or streaming),
25
+ // false if the caller (server.mjs) should fall through to static file serving.
26
+ async function dispatch(req, res, url, cwd) {
27
+ const pathname = url.pathname;
28
+
29
+ // SSE
30
+ if (pathname === '/api/sse') {
31
+ res.writeHead(200, {
32
+ 'Content-Type': 'text/event-stream',
33
+ 'Cache-Control': 'no-cache',
34
+ 'Connection': 'keep-alive',
35
+ });
36
+ res._gctoCwd = cwd; // remember which project this client wants
37
+ sseClients.add(res);
38
+ res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
39
+ req.on('close', () => sseClients.delete(res));
40
+ return true;
41
+ }
42
+
43
+ // API
44
+ if (pathname === '/api/projects') {
45
+ res.writeHead(200, { 'Content-Type': 'application/json' });
46
+ res.end(JSON.stringify(listProjects()));
47
+ return true;
48
+ }
49
+
50
+ // Manually register a project at an arbitrary path (e.g. /tmp/...).
51
+ // Body: { path: "/tmp/neobank-test" }
52
+ //
53
+ // BH-23 (Security): this endpoint creates files + registers projects, so
54
+ // it MUST reject cross-origin requests. The board listens on 127.0.0.1
55
+ // but a malicious page the user visits can still issue text/plain POSTs
56
+ // (simple CORS request — no preflight) to localhost. Two gates:
57
+ // 1) Origin / Referer must match http://localhost:PORT or 127.0.0.1:PORT.
58
+ // 2) Resolved target path must live inside HOME — no /tmp, no /etc.
59
+ if (pathname === '/api/projects/register' && req.method === 'POST') {
60
+ const origin = req.headers.origin || req.headers.referer || '';
61
+ const expectedOrigin = `http://localhost:${PORT}`;
62
+ const expectedOrigin2 = `http://127.0.0.1:${PORT}`;
63
+ const originOk = !origin
64
+ || origin === expectedOrigin
65
+ || origin === expectedOrigin2
66
+ || origin.startsWith(expectedOrigin + '/')
67
+ || origin.startsWith(expectedOrigin2 + '/');
68
+ if (!originOk) {
69
+ res.writeHead(403, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({ error: 'origin not allowed' }));
71
+ return true;
72
+ }
73
+ let body = '';
74
+ req.on('data', c => body += c);
75
+ req.on('end', () => {
76
+ try {
77
+ const { path: projPath } = JSON.parse(body || '{}');
78
+ if (!projPath || typeof projPath !== 'string') {
79
+ res.writeHead(400, { 'Content-Type': 'application/json' });
80
+ return res.end(JSON.stringify({ error: 'path missing' }));
81
+ }
82
+ const resolved = path.resolve(projPath);
83
+ const home = os.homedir();
84
+ if (!resolved.startsWith(home + path.sep) && resolved !== home) {
85
+ res.writeHead(403, { 'Content-Type': 'application/json' });
86
+ return res.end(JSON.stringify({ error: 'path must live inside HOME' }));
87
+ }
88
+ if (!fs.existsSync(resolved)) {
89
+ res.writeHead(400, { 'Content-Type': 'application/json' });
90
+ return res.end(JSON.stringify({ error: 'path does not exist' }));
91
+ }
92
+ const greatCtoDir = path.join(resolved, '.great_cto');
93
+ const projectMd = path.join(greatCtoDir, 'PROJECT.md');
94
+ if (!fs.existsSync(projectMd)) {
95
+ fs.mkdirSync(greatCtoDir, { recursive: true });
96
+ fs.writeFileSync(projectMd, `# PROJECT — ${path.basename(resolved)}\n\nname: ${path.basename(resolved)}\narchetype: unknown\nphase: discovery\n`);
97
+ }
98
+ autoRegisterProject(resolved);
99
+ res.writeHead(200, { 'Content-Type': 'application/json' });
100
+ res.end(JSON.stringify({ ok: true, path: resolved, slug: path.basename(resolved) }));
101
+ } catch (e) {
102
+ res.writeHead(500, { 'Content-Type': 'application/json' });
103
+ res.end(JSON.stringify({ error: e.message }));
104
+ }
105
+ });
106
+ return true;
107
+ }
108
+
109
+ if (pathname === '/api/tasks' && req.method === 'GET') {
110
+ res.writeHead(200, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify(getTasks(cwd)));
112
+ return true;
113
+ }
114
+
115
+ if (pathname === '/api/metrics') {
116
+ let days = parseInt(url.searchParams.get('days') || '30', 10);
117
+ if (!Number.isFinite(days) || days < 1) days = 30;
118
+ if (days > 365) days = 365;
119
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
120
+ res.end(JSON.stringify(getMetrics(cwd, days)));
121
+ return true;
122
+ }
123
+
124
+ // ── /api/notifications — email alerts via greatcto.systems/notify relay ──
125
+ // No API keys to manage — user enters only their email + verifies via
126
+ // 6-digit code sent by our Cloudflare Worker. The Worker rate-limits to
127
+ // 100 emails/24h per verified email.
128
+ //
129
+ // Local state in ~/.great_cto/notifications.json:
130
+ // { "to": "user@example.com", "verified": true, "enabled": true,
131
+ // "triggers": ["incident.p0", ...] }
132
+ if (pathname === '/api/notifications') {
133
+ const NOTIFY_RELAY = process.env.GREATCTO_NOTIFY_URL || 'https://greatcto.systems';
134
+ const stateFile = path.join(GREAT_CTO_DIR, 'notifications.json');
135
+ const KNOWN_TRIGGERS = ['incident.p0', 'gate.stale', 'gate.blocked', 'cost.threshold', 'digest.weekly'];
136
+ let state = { to: '', verified: false, enabled: false, triggers: [] };
137
+ try { Object.assign(state, JSON.parse(fs.readFileSync(stateFile, 'utf8'))); } catch {}
138
+
139
+ function saveState() {
140
+ if (!fs.existsSync(GREAT_CTO_DIR)) fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
141
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
142
+ }
143
+
144
+ if (req.method === 'GET') {
145
+ res.writeHead(200, { 'Content-Type': 'application/json' });
146
+ res.end(JSON.stringify({
147
+ to: state.to || '',
148
+ verified: !!state.verified,
149
+ enabled: !!state.enabled,
150
+ triggers: state.triggers || [],
151
+ known_triggers: KNOWN_TRIGGERS,
152
+ relay: NOTIFY_RELAY,
153
+ }));
154
+ return true;
155
+ }
156
+ if (req.method === 'POST') {
157
+ let body = '';
158
+ req.on('data', c => body += c);
159
+ req.on('end', async () => {
160
+ let parsed;
161
+ try { parsed = JSON.parse(body || '{}'); }
162
+ catch {
163
+ res.writeHead(400); return res.end(JSON.stringify({ error: 'invalid_json' }));
164
+ }
165
+ const action = parsed.action || 'save';
166
+
167
+ // ── verify: ask the worker to send a 6-digit code to <to> ──
168
+ if (action === 'verify') {
169
+ const to = String(parsed.to || '').trim().toLowerCase();
170
+ if (!to) { res.writeHead(400); return res.end(JSON.stringify({ error: 'to_required' })); }
171
+ try {
172
+ const r = await fetch(`${NOTIFY_RELAY}/notify/verify`, {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({ to }),
176
+ });
177
+ const j = await r.json().catch(() => null);
178
+ // Persist email (still unverified) so the UI can show the pending state
179
+ state.to = to;
180
+ state.verified = false;
181
+ saveState();
182
+ res.writeHead(r.ok ? 200 : (r.status || 502), { 'Content-Type': 'application/json' });
183
+ return res.end(JSON.stringify(j || { error: 'relay_unreachable' }));
184
+ } catch (e) {
185
+ res.writeHead(502); return res.end(JSON.stringify({ error: e.message }));
186
+ }
187
+ }
188
+
189
+ // ── confirm: send the user-typed code to the worker, mark verified on 200 ──
190
+ if (action === 'confirm') {
191
+ const to = String(parsed.to || state.to || '').trim().toLowerCase();
192
+ const code = String(parsed.code || '').trim();
193
+ if (!to) { res.writeHead(400); return res.end(JSON.stringify({ error: 'to_required' })); }
194
+ if (!code) { res.writeHead(400); return res.end(JSON.stringify({ error: 'code_required' })); }
195
+ try {
196
+ const r = await fetch(`${NOTIFY_RELAY}/notify/confirm`, {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json' },
199
+ body: JSON.stringify({ to, code }),
200
+ });
201
+ const j = await r.json().catch(() => null);
202
+ if (r.ok) {
203
+ state.to = to;
204
+ state.verified = true;
205
+ saveState();
206
+ }
207
+ res.writeHead(r.ok ? 200 : (r.status || 502), { 'Content-Type': 'application/json' });
208
+ return res.end(JSON.stringify(j || { error: 'relay_unreachable' }));
209
+ } catch (e) {
210
+ res.writeHead(502); return res.end(JSON.stringify({ error: e.message }));
211
+ }
212
+ }
213
+
214
+ // ── save: persist triggers + enabled flag locally ──
215
+ if (action === 'save') {
216
+ const triggers = Array.isArray(parsed.triggers)
217
+ ? parsed.triggers.filter(t => KNOWN_TRIGGERS.includes(t))
218
+ : (state.triggers || []);
219
+ state.triggers = triggers;
220
+ if (parsed.enabled != null) state.enabled = !!parsed.enabled;
221
+ saveState();
222
+ res.writeHead(200, { 'Content-Type': 'application/json' });
223
+ return res.end(JSON.stringify({ ok: true, verified: state.verified, enabled: state.enabled }));
224
+ }
225
+
226
+ // ── test: fire a synthetic alert through the relay ──
227
+ if (action === 'test') {
228
+ if (!state.verified || !state.to) {
229
+ res.writeHead(400); return res.end(JSON.stringify({ error: 'verify_first' }));
230
+ }
231
+ try {
232
+ const r = await fetch(`${NOTIFY_RELAY}/notify`, {
233
+ method: 'POST',
234
+ headers: { 'Content-Type': 'application/json' },
235
+ body: JSON.stringify({
236
+ to: state.to,
237
+ title: '🧪 GreatCTO test alert',
238
+ body: 'If you see this, the email alert pipeline is working end-to-end (board → Cloudflare worker → Resend → inbox).',
239
+ level: 'info',
240
+ project: 'great_cto',
241
+ link: 'http://localhost:3141/#notifications',
242
+ action: 'Open Notifications tab',
243
+ kv: { test: 'ok', sent_at: new Date().toISOString() },
244
+ event: 'test',
245
+ }),
246
+ });
247
+ const j = await r.json().catch(() => null);
248
+ res.writeHead(r.ok ? 200 : (r.status || 502), { 'Content-Type': 'application/json' });
249
+ return res.end(JSON.stringify({ ok: r.ok, response: j }));
250
+ } catch (e) {
251
+ res.writeHead(502); return res.end(JSON.stringify({ error: e.message }));
252
+ }
253
+ }
254
+
255
+ res.writeHead(400); return res.end(JSON.stringify({ error: 'unknown_action' }));
256
+ });
257
+ return true;
258
+ }
259
+ }
260
+
261
+ if (pathname === '/api/share') {
262
+ if (req.method === 'GET') {
263
+ res.writeHead(200, { 'Content-Type': 'application/json' });
264
+ res.end(JSON.stringify(getShareState(cwd)));
265
+ return true;
266
+ }
267
+ if (req.method === 'POST') {
268
+ let body = '';
269
+ req.on('data', c => body += c);
270
+ req.on('end', async () => {
271
+ // BH-24: malformed JSON used to throw inside the async handler,
272
+ // turning into an unhandled rejection and hanging the request.
273
+ // Sibling endpoints (/status, /priority, /gates) were fixed in
274
+ // PR #40 (BH-14); /api/share was missed.
275
+ let parsed;
276
+ try { parsed = JSON.parse(body || '{}'); }
277
+ catch {
278
+ res.writeHead(400, { 'Content-Type': 'application/json' });
279
+ return res.end(JSON.stringify({ error: 'invalid_json' }));
280
+ }
281
+ try {
282
+ const state = await toggleShare(parsed.enabled, cwd, !!parsed.force);
283
+ res.writeHead(200, { 'Content-Type': 'application/json' });
284
+ res.end(JSON.stringify(state));
285
+ } catch (e) {
286
+ res.writeHead(500, { 'Content-Type': 'application/json' });
287
+ res.end(JSON.stringify({ error: e.message }));
288
+ }
289
+ });
290
+ return true;
291
+ }
292
+ }
293
+
294
+ // ── Read a project doc (markdown) referenced from a task — for the side-panel viewer.
295
+ // Path-traversal-safe: the resolved path must stay inside the project cwd; .md only.
296
+ if (pathname === '/api/doc' && req.method === 'GET') {
297
+ const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
298
+ const rel = String(url.searchParams.get('path') || '');
299
+ const abs = path.resolve(c, rel);
300
+ if (!rel || !abs.startsWith(path.resolve(c) + path.sep) || !abs.toLowerCase().endsWith('.md')) {
301
+ res.writeHead(400, { 'Content-Type': 'application/json' });
302
+ res.end(JSON.stringify({ error: 'path must be a .md file inside the project' }));
303
+ return true;
304
+ }
305
+ try {
306
+ const st = fs.statSync(abs);
307
+ if (!st.isFile() || st.size > 1024 * 1024) throw new Error('not a readable doc');
308
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
309
+ res.end(JSON.stringify({ name: path.basename(abs), path: rel, content: fs.readFileSync(abs, 'utf8') }));
310
+ } catch {
311
+ res.writeHead(404, { 'Content-Type': 'application/json' });
312
+ res.end(JSON.stringify({ error: 'doc not found: ' + rel }));
313
+ }
314
+ return true;
315
+ }
316
+ // (board-triggered agent launch removed — the board no longer spawns coding agents)
317
+
318
+ // Gate approval / rejection
319
+ if (pathname.startsWith('/api/gates/') && req.method === 'POST') {
320
+ const id = pathname.replace('/api/gates/', '');
321
+ let body = '';
322
+ req.on('data', c => body += c);
323
+ req.on('end', async () => {
324
+ // BH-14a: catch JSON parse error explicitly → 400 (was 500/uncaught)
325
+ let parsed;
326
+ try {
327
+ parsed = JSON.parse(body || '{}');
328
+ } catch (e) {
329
+ res.writeHead(400, { 'Content-Type': 'application/json' });
330
+ res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
331
+ return;
332
+ }
333
+ const { action, reason } = parsed;
334
+ const gateCwd = parsed.project ? resolveProjectCwd(parsed.project) : cwd;
335
+ if (!['approve', 'reject'].includes(action)) {
336
+ res.writeHead(400, { 'Content-Type': 'application/json' });
337
+ res.end(JSON.stringify({ error: 'invalid action' }));
338
+ return;
339
+ }
340
+ const beadsErr = checkBeadsAvailable(gateCwd);
341
+ if (beadsErr) {
342
+ res.writeHead(409, { 'Content-Type': 'application/json' });
343
+ res.end(JSON.stringify(beadsErr));
344
+ return;
345
+ }
346
+ // BH-16 fix: serialise gate writes through bd-write queue.
347
+ // Without this, concurrent approve+reject on the same gate produced
348
+ // TWO appendDecisionLog entries (one wrong) — log says approved AND
349
+ // rejected. bdWriteSerialised guarantees one-at-a-time semantics.
350
+ const result = await bdWriteSerialised(() => {
351
+ const status = action === 'approve' ? 'closed' : 'blocked';
352
+ const args = ['update', id, '--status', status];
353
+ if (reason) args.push('--notes', `[${action}] ${reason}`);
354
+ const r = bd(args, { cwd: gateCwd, timeout: 5000 });
355
+ if (r.status !== 0) return { error: bdErr(r, 'bd update failed') };
356
+ bdCacheInvalidate(gateCwd);
357
+ // Append to global decisions log — still inside the lock window
358
+ try {
359
+ const projectSlug = parsed.project || path.basename(gateCwd);
360
+ const allTasks = getTasks(gateCwd);
361
+ const gateTask = allTasks.find(t => t.id === id);
362
+ const title = gateTask?.title || id;
363
+ appendDecisionLog({
364
+ ts: new Date().toISOString(),
365
+ project: projectSlug,
366
+ action,
367
+ id,
368
+ title,
369
+ reason: reason || '',
370
+ });
371
+ } catch { /* best-effort */ }
372
+ return { ok: true };
373
+ });
374
+ if (!result || result.error) {
375
+ res.writeHead(500, { 'Content-Type': 'application/json' });
376
+ res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
377
+ return;
378
+ }
379
+ res.writeHead(200, { 'Content-Type': 'application/json' });
380
+ res.end(JSON.stringify({ ok: true, id, action }));
381
+ broadcastTasks(cwd);
382
+ // Auto-republish share report when a gate is approved (fire-and-forget)
383
+ if (action === 'approve') {
384
+ const shareState = getShareState(gateCwd);
385
+ if (shareState.enabled) {
386
+ toggleShare(true, gateCwd, true)
387
+ .then(() => console.log(`report: auto-republished after gate approve (${id})`))
388
+ .catch(e => console.warn(`report: republish after gate failed: ${e.message}`));
389
+ }
390
+ }
391
+ });
392
+ return true;
393
+ }
394
+
395
+ // Inbox — what needs your attention right now
396
+ if (pathname === '/api/inbox') {
397
+ res.writeHead(200, { 'Content-Type': 'application/json' });
398
+ res.end(JSON.stringify(getInbox(cwd)));
399
+ return true;
400
+ }
401
+
402
+ // Resume — pick up where you left off (last verdicts + WIP + recent decisions)
403
+ if (pathname === '/api/resume') {
404
+ res.writeHead(200, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify(getResume(cwd)));
406
+ return true;
407
+ }
408
+
409
+ // Decisions log — global ADR-style log across all projects
410
+ if (pathname === '/api/decisions') {
411
+ // Clamp `limit` to [1, 200]. Same defensive pattern as /api/cost?days
412
+ // — handle ?limit=abc / ?limit=0 / ?limit=-5 / ?limit=999 deterministically.
413
+ const rawLimit = url.searchParams.get('limit');
414
+ const parsed = rawLimit != null ? parseInt(rawLimit, 10) : 20;
415
+ const limit = Number.isFinite(parsed) && parsed > 0
416
+ ? Math.min(parsed, 200)
417
+ : 20;
418
+ res.writeHead(200, { 'Content-Type': 'application/json' });
419
+ res.end(JSON.stringify(readDecisionsLog(limit)));
420
+ return true;
421
+ }
422
+
423
+ // Memory — 4-layer memory file contents
424
+ if (pathname === '/api/memory') {
425
+ res.writeHead(200, { 'Content-Type': 'application/json' });
426
+ res.end(JSON.stringify(getMemory(cwd)));
427
+ return true;
428
+ }
429
+
430
+ // Pipeline — current stage states (idle / active / done / failed)
431
+ if (pathname === '/api/pipeline') {
432
+ res.writeHead(200, { 'Content-Type': 'application/json' });
433
+ res.end(JSON.stringify(getPipeline(cwd)));
434
+ return true;
435
+ }
436
+
437
+ // change_tier for the current working-tree diff — the gate + judge plan (ADR-003/004).
438
+ if (pathname === '/api/change-tier') {
439
+ res.writeHead(200, { 'Content-Type': 'application/json' });
440
+ res.end(JSON.stringify(getChangeTier(cwd)));
441
+ return true;
442
+ }
443
+
444
+ // Cost history — daily LLM burn over N days.
445
+ // Clamp `days` to [1, 365] to defend against:
446
+ // ?days=abc → NaN → default 30 (parseInt fallback to 30)
447
+ // ?days=999 → 1000-bucket response (memory + payload bloat)
448
+ // ?days=-5 → empty series, daily_avg = null in UI
449
+ // ?days=0 → division-by-zero in daily_avg calc
450
+ // 365 is enough for "last year" views; anything bigger should use
451
+ // a different endpoint / batch query path.
452
+ if (pathname === '/api/cost') {
453
+ const rawDays = url.searchParams.get('days');
454
+ const parsed = rawDays != null ? parseInt(rawDays, 10) : 30;
455
+ const days = Number.isFinite(parsed) && parsed > 0
456
+ ? Math.min(parsed, 365)
457
+ : 30;
458
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
459
+ res.end(JSON.stringify(getCostHistory(cwd, days)));
460
+ return true;
461
+ }
462
+
463
+
464
+ // Create new task
465
+ if (pathname === '/api/tasks' && req.method === 'POST') {
466
+ let body = '';
467
+ req.on('data', c => body += c);
468
+ req.on('end', async () => {
469
+ // Validation hardening (2026-05-15): bug-hunt found 3 ways to crash this
470
+ // endpoint or silently drop bad input:
471
+ // - invalid JSON body → 500 (parser exception in catch → 500)
472
+ // - 10K-char title → 500 (bd argv too long)
473
+ // - priority=99 → 200 (silently ignored, user thinks it worked)
474
+ // Each is now an explicit 400 with structured error.
475
+ let parsed;
476
+ try {
477
+ parsed = JSON.parse(body || '{}');
478
+ } catch (e) {
479
+ res.writeHead(400, { 'Content-Type': 'application/json' });
480
+ res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
481
+ return;
482
+ }
483
+ try {
484
+ const { title, description, priority, agent, labels } = parsed;
485
+ if (!title || typeof title !== 'string' || !title.trim()) {
486
+ res.writeHead(400, { 'Content-Type': 'application/json' });
487
+ res.end(JSON.stringify({ error: 'title required' }));
488
+ return;
489
+ }
490
+ // Title length bound: bd issue titles practically cap around 200 chars;
491
+ // 500 is a safe ceiling that catches obvious junk while allowing
492
+ // long-form summaries when warranted.
493
+ if (title.length > 500) {
494
+ res.writeHead(400, { 'Content-Type': 'application/json' });
495
+ res.end(JSON.stringify({
496
+ error: 'title_too_long',
497
+ message: `Title is ${title.length} chars; max 500 allowed.`,
498
+ length: title.length,
499
+ }));
500
+ return;
501
+ }
502
+ // Priority must be in P0–P3 if specified. Silent ignore was hiding
503
+ // typos like priority=11 (probably meant P1).
504
+ if (priority != null && (typeof priority !== 'number' || priority < 0 || priority > 3)) {
505
+ res.writeHead(400, { 'Content-Type': 'application/json' });
506
+ res.end(JSON.stringify({
507
+ error: 'invalid_priority',
508
+ message: 'priority must be an integer in [0, 3] (P0–P3).',
509
+ received: priority,
510
+ }));
511
+ return;
512
+ }
513
+ const beadsErr = checkBeadsAvailable(cwd);
514
+ if (beadsErr) {
515
+ res.writeHead(409, { 'Content-Type': 'application/json' });
516
+ res.end(JSON.stringify(beadsErr));
517
+ return;
518
+ }
519
+ // Build bd create args
520
+ const args = ['create', title.trim()];
521
+ if (description) args.push('-d', description);
522
+ if (priority != null && priority >= 0 && priority <= 3) args.push('--priority', `P${priority}`);
523
+
524
+ // BH-12 fix: serialise bd writes through global write chain.
525
+ // Concurrent POST /api/tasks calls used to race on bd's file lock —
526
+ // one crash would leave a stale .beads/.lock that froze ALL writes.
527
+ const result = await bdWriteSerialised(() => {
528
+ const r = bd(args, { cwd, timeout: 5000 });
529
+ if (r.status !== 0) return { error: bdErr(r, 'bd create failed') };
530
+ const idMatch = (r.stdout || '').match(/Created issue:\s*(\S+)/);
531
+ const id = idMatch ? idMatch[1] : null;
532
+
533
+ // Apply optional labels + agent within the same lock window
534
+ if (id) {
535
+ const updateArgs = ['update', id];
536
+ let needUpdate = false;
537
+ if (agent) { updateArgs.push('--assignee', agent); needUpdate = true; }
538
+ const lbls = Array.isArray(labels) ? labels : (labels ? [labels] : []);
539
+ for (const lbl of lbls) {
540
+ if (lbl) { updateArgs.push('--add-label', lbl); needUpdate = true; }
541
+ }
542
+ if (agent && !lbls.includes(agent)) { updateArgs.push('--add-label', agent); needUpdate = true; }
543
+ if (needUpdate) bd(updateArgs, { cwd, timeout: 5000 });
544
+ }
545
+ return { id };
546
+ });
547
+
548
+ if (!result || result.error) {
549
+ res.writeHead(500, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify({ error: (result && result.error) || 'bd create failed' }));
551
+ return;
552
+ }
553
+
554
+ bdCacheInvalidate(cwd);
555
+ broadcastTasks(cwd);
556
+ res.writeHead(200, { 'Content-Type': 'application/json' });
557
+ res.end(JSON.stringify({ ok: true, id: result.id }));
558
+ } catch (e) {
559
+ res.writeHead(500, { 'Content-Type': 'application/json' });
560
+ res.end(JSON.stringify({ error: String(e.message || e) }));
561
+ }
562
+ });
563
+ return true;
564
+ }
565
+
566
+ // Task status update — BH-14/BH-16 fixes: JSON parse 400, write serialisation
567
+ if (pathname.match(/^\/api\/tasks\/[^/]+\/status$/) && req.method === 'POST') {
568
+ const id = pathname.split('/')[3];
569
+ let body = '';
570
+ req.on('data', c => body += c);
571
+ req.on('end', async () => {
572
+ let parsed;
573
+ try {
574
+ parsed = JSON.parse(body || '{}');
575
+ } catch (e) {
576
+ res.writeHead(400, { 'Content-Type': 'application/json' });
577
+ res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
578
+ return;
579
+ }
580
+ const { status } = parsed;
581
+ const validStatuses = ['open', 'in_progress', 'blocked', 'closed'];
582
+ if (!validStatuses.includes(status)) {
583
+ res.writeHead(400, { 'Content-Type': 'application/json' });
584
+ res.end(JSON.stringify({ error: 'invalid_status', message: `status must be one of: ${validStatuses.join(', ')}`, received: status }));
585
+ return;
586
+ }
587
+ const result = await bdWriteSerialised(() => {
588
+ const r = bd(['update', id, '--status', status], { cwd, timeout: 5000 });
589
+ if (r.status !== 0) return { error: bdErr(r, 'bd update failed') };
590
+ bdCacheInvalidate(cwd);
591
+ return { ok: true };
592
+ });
593
+ if (!result || result.error) {
594
+ res.writeHead(500, { 'Content-Type': 'application/json' });
595
+ res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
596
+ return;
597
+ }
598
+ broadcastTasks(cwd);
599
+ res.writeHead(200, { 'Content-Type': 'application/json' });
600
+ res.end(JSON.stringify({ ok: true }));
601
+ });
602
+ return true;
603
+ }
604
+
605
+ // Task priority update
606
+ if (pathname.match(/^\/api\/tasks\/[^/]+\/priority$/) && req.method === 'POST') {
607
+ const id = pathname.split('/')[3];
608
+ let body = '';
609
+ req.on('data', c => body += c);
610
+ req.on('end', async () => {
611
+ let parsed;
612
+ try {
613
+ parsed = JSON.parse(body || '{}');
614
+ } catch (e) {
615
+ res.writeHead(400, { 'Content-Type': 'application/json' });
616
+ res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
617
+ return;
618
+ }
619
+ const { priority } = parsed;
620
+ if (priority == null || typeof priority !== 'number' || priority < 0 || priority > 3) {
621
+ res.writeHead(400, { 'Content-Type': 'application/json' });
622
+ res.end(JSON.stringify({ error: 'invalid_priority', message: 'priority must be an integer in [0, 3]', received: priority }));
623
+ return;
624
+ }
625
+ const result = await bdWriteSerialised(() => {
626
+ const r = bd(['update', id, '--priority', String(priority)], { cwd, timeout: 5000 });
627
+ if (r.status !== 0) return { error: bdErr(r, 'bd update failed') };
628
+ bdCacheInvalidate(cwd);
629
+ return { ok: true };
630
+ });
631
+ if (!result || result.error) {
632
+ res.writeHead(500, { 'Content-Type': 'application/json' });
633
+ res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
634
+ return;
635
+ }
636
+ broadcastTasks(cwd);
637
+ res.writeHead(200, { 'Content-Type': 'application/json' });
638
+ res.end(JSON.stringify({ ok: true }));
639
+ });
640
+ return true;
641
+ }
642
+
643
+ // Task history / timeline from interactions.jsonl
644
+ if (pathname.match(/^\/api\/tasks\/[^/]+\/history$/) && req.method === 'GET') {
645
+ const taskId = pathname.split('/')[3];
646
+ // 404 for unknown task IDs so the UI can distinguish "task does not exist"
647
+ // from "task exists but has no history yet".
648
+ const allTasks = getTasks(cwd);
649
+ if (!allTasks.some(t => t.id === taskId)) {
650
+ res.writeHead(404, { 'Content-Type': 'application/json' });
651
+ res.end(JSON.stringify({ error: 'task_not_found', id: taskId }));
652
+ return true;
653
+ }
654
+ const interactionsFile = path.join(cwd, '.beads', 'interactions.jsonl');
655
+ if (!fs.existsSync(interactionsFile)) {
656
+ res.writeHead(200, { 'Content-Type': 'application/json' });
657
+ res.end(JSON.stringify({ events: [] }));
658
+ return true;
659
+ }
660
+ try {
661
+ const lines = fs.readFileSync(interactionsFile, 'utf8').split('\n').filter(Boolean);
662
+ const events = [];
663
+ for (const line of lines) {
664
+ try {
665
+ const obj = JSON.parse(line);
666
+ if (obj.id === taskId) {
667
+ events.push({
668
+ ts: obj.ts || obj.created_at || null,
669
+ actor: obj.actor || obj.agent || null,
670
+ action: obj.action || obj.type || 'updated',
671
+ from: obj.from || null,
672
+ to: obj.to || null,
673
+ notes: obj.notes || null,
674
+ });
675
+ }
676
+ } catch {}
677
+ }
678
+ res.writeHead(200, { 'Content-Type': 'application/json' });
679
+ res.end(JSON.stringify({ events }));
680
+ } catch (e) {
681
+ res.writeHead(500, { 'Content-Type': 'application/json' });
682
+ res.end(JSON.stringify({ error: String(e.message || e) }));
683
+ }
684
+ return true;
685
+ }
686
+
687
+ // Memory — single global pattern content
688
+ if (pathname === '/api/memory-pattern') {
689
+ const id = url.searchParams.get('id') || '';
690
+ if (!/^GP-[A-Za-z0-9_-]+$/.test(id)) {
691
+ res.writeHead(400, { 'Content-Type': 'application/json' });
692
+ res.end(JSON.stringify({ error: 'invalid id' }));
693
+ return true;
694
+ }
695
+ const fp = path.join(GREAT_CTO_DIR, 'global-patterns', id + '.md');
696
+ const content = readFileSafe(fp);
697
+ res.writeHead(content == null ? 404 : 200, { 'Content-Type': 'application/json' });
698
+ res.end(JSON.stringify({ content: content || null }));
699
+ return true;
700
+ }
701
+
702
+ // Session logs — list .great_cto/logs/session-*.md for the current project.
703
+ // When no /save logs exist, synthesize day-grouped entries from verdicts so
704
+ // the panel is useful immediately — every project with agent activity gets
705
+ // a meaningful log even before the first /save.
706
+ if (pathname === '/api/logs') {
707
+ const logsDir = path.join(cwd, '.great_cto', 'logs');
708
+ let logs = [];
709
+ try {
710
+ const files = fs.readdirSync(logsDir)
711
+ .filter(f => f.startsWith('session-') && f.endsWith('.md'))
712
+ .sort().reverse().slice(0, 30);
713
+ logs = files.map(f => {
714
+ const fp = path.join(logsDir, f);
715
+ const raw = readFileSafe(fp) || '';
716
+ const dateM = raw.match(/^date:\s*(.+)$/m);
717
+ const timeM = raw.match(/^time:\s*(.+)$/m);
718
+ const durM = raw.match(/^duration:\s*(.+)$/m);
719
+ const titleM = raw.match(/^#\s+Session:\s*(.+)$/m);
720
+ const doneM = raw.match(/## Done\n([\s\S]*?)(?=\n##|$)/);
721
+ let done = doneM ? doneM[1].trim().split('\n').filter(l => l.startsWith('- ')).map(l => l.slice(2)) : [];
722
+ const pendM = raw.match(/## Pending\n([\s\S]*?)(?=\n##|$)/);
723
+ let pending = pendM ? pendM[1].trim().split('\n').filter(l => l.startsWith('- ')).map(l => l.slice(2)) : [];
724
+
725
+ // v2.7.0: SessionEnd hook auto-captures use a different schema
726
+ // (## Git / ## Beads / ## Cost). When no /save format found,
727
+ // synthesise done/pending bullets from those sections so the
728
+ // panel still has content.
729
+ let source = 'save';
730
+ if (!done.length && !pending.length) {
731
+ source = 'auto';
732
+ const gitM = raw.match(/## Git\n([\s\S]*?)(?=\n##|$)/);
733
+ const beadsM = raw.match(/## Beads\n([\s\S]*?)(?=\n##|$)/);
734
+ const costM = raw.match(/## Cost[^\n]*\n([\s\S]*?)(?=\n##|$)/);
735
+ const bullets = (sec) => sec ? sec[1].trim().split('\n').filter(l => l.startsWith('- ')).map(l => l.slice(2)) : [];
736
+ // Done = factual snapshot (Git + Cost)
737
+ done = [...bullets(gitM)];
738
+ if (costM) {
739
+ const costLines = costM[1].trim().split('\n').filter(l => l && !l.startsWith('```') && !l.includes('(no cost log)'));
740
+ if (costLines.length) done.push(`Cost: ${costLines.join(' · ')}`);
741
+ }
742
+ // Pending = open work (Beads)
743
+ pending = [...bullets(beadsM)];
744
+ }
745
+ return {
746
+ file: f,
747
+ source,
748
+ date: dateM?.[1]?.trim() || f.slice(8, 18),
749
+ time: timeM?.[1]?.trim() || '',
750
+ duration: durM?.[1]?.trim() || '',
751
+ title: titleM?.[1]?.trim() || f.replace(/^session-\d{4}-\d{2}-\d{2}-/, '').replace('.md', ''),
752
+ done,
753
+ pending,
754
+ raw,
755
+ };
756
+ });
757
+ } catch {}
758
+
759
+ // Fallback: synthesize from verdicts grouped by day
760
+ if (!logs.length) {
761
+ try {
762
+ const verdicts = readVerdicts();
763
+ // Filter to verdicts referencing this project (best-effort: include all
764
+ // when project-tagging not available)
765
+ const byDay = new Map();
766
+ for (const v of verdicts) {
767
+ const day = (v.ts || '').slice(0, 10);
768
+ if (!day) continue;
769
+ if (!byDay.has(day)) byDay.set(day, { ok: [], fail: [], earliest: v.ts, latest: v.ts });
770
+ const b = byDay.get(day);
771
+ const verdictUp = (v.verdict || '').toUpperCase();
772
+ const isOk = ['OK','APPROVED','DONE','PASS','PASSED'].includes(verdictUp);
773
+ const isFail = ['FAIL','FAILED','BLOCKED','REJECTED'].includes(verdictUp);
774
+ const summary = `${v.agent}: ${v.verdict || 'event'}${v.raw ? ` — ${v.raw.replace(/\s+/g,' ').slice(0,140)}` : ''}`;
775
+ if (isFail) b.fail.push(summary);
776
+ else b.ok.push(summary);
777
+ if (v.ts < b.earliest) b.earliest = v.ts;
778
+ if (v.ts > b.latest) b.latest = v.ts;
779
+ }
780
+ logs = Array.from(byDay.entries())
781
+ .sort(([a],[b]) => b.localeCompare(a))
782
+ .slice(0, 30)
783
+ .map(([day, b]) => ({
784
+ file: `auto-${day}`,
785
+ source: 'verdicts',
786
+ date: day,
787
+ time: (b.earliest || '').slice(11, 16),
788
+ duration: '',
789
+ title: `Auto-log · ${b.ok.length + b.fail.length} agent run${(b.ok.length+b.fail.length)===1?'':'s'}`,
790
+ done: b.ok.slice(0, 50),
791
+ pending: b.fail.slice(0, 50),
792
+ raw: '_Auto-generated from ~/.great_cto/verdicts/. Run `/save` to create a curated session log._',
793
+ }));
794
+ } catch {}
795
+ }
796
+
797
+ res.writeHead(200, { 'Content-Type': 'application/json' });
798
+ res.end(JSON.stringify({ logs }));
799
+ return true;
800
+ }
801
+
802
+ // Installed agents — fleet view (DESIGN-agents-fleet-view §3.1).
803
+ //
804
+ // Extended in 2026-05-15 from a flat list to a faceted-fleet payload:
805
+ // each row carries domain (slug-derived), runs_30d, success_rate, last_run,
806
+ // retired (sidecar file marker), and savings_x. The board's /agents tab
807
+ // renders these into the .agent-row list with no further server round-trips.
808
+ if (pathname === '/api/agents-installed') {
809
+ res.writeHead(200, { 'Content-Type': 'application/json' });
810
+ res.end(JSON.stringify(getAgentsFleet(cwd)));
811
+ return true;
812
+ }
813
+
814
+ // Build version — so the board shows which great_cto version it's running.
815
+ if (pathname === '/api/version') {
816
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
817
+ res.end(JSON.stringify({ version: BUILD_VERSION, surface: 'builder', node: process.version.replace(/^v/, '') }));
818
+ return true;
819
+ }
820
+
821
+ // Per-agent profile drawer (DESIGN-agents-fleet-view §3.2).
822
+ // GET /api/agents/<slug> → { slug, description, model, applies_to,
823
+ // runs_30d, success_rate, last_run, savings_x,
824
+ // retired, runs[20], failure_modes[N] }
825
+ if (pathname.startsWith('/api/agents/') && req.method === 'GET') {
826
+ const rest = pathname.slice('/api/agents/'.length);
827
+ const [slug, sub] = rest.split('/');
828
+ if (!slug || !/^[a-z0-9-]+$/i.test(slug)) {
829
+ res.writeHead(400, { 'Content-Type': 'application/json' });
830
+ res.end(JSON.stringify({ error: 'invalid_slug' }));
831
+ return true;
832
+ }
833
+ if (!sub) {
834
+ const profile = getAgentProfile(slug);
835
+ if (!profile) {
836
+ res.writeHead(404, { 'Content-Type': 'application/json' });
837
+ res.end(JSON.stringify({ error: 'agent_not_found', slug }));
838
+ return true;
839
+ }
840
+ res.writeHead(200, { 'Content-Type': 'application/json' });
841
+ res.end(JSON.stringify(profile));
842
+ return true;
843
+ }
844
+ res.writeHead(404, { 'Content-Type': 'application/json' });
845
+ res.end(JSON.stringify({ error: 'unknown_subpath', sub }));
846
+ return true;
847
+ }
848
+
849
+ // POST /api/agents/<slug>/retire | restore — sidecar marker
850
+ // (DESIGN-agents-fleet-view §9 Top-2 #2: reversible sidecar chosen over
851
+ // filesystem move; founder may revise.)
852
+ if (pathname.startsWith('/api/agents/') && req.method === 'POST') {
853
+ // Cross-origin guard — same pattern as BH-23 on /api/projects/register.
854
+ const origin = req.headers.origin || req.headers.referer || '';
855
+ const expectedOrigin = `http://localhost:${PORT}`;
856
+ const expectedOrigin2 = `http://127.0.0.1:${PORT}`;
857
+ const originOk = !origin
858
+ || origin === expectedOrigin
859
+ || origin === expectedOrigin2
860
+ || origin.startsWith(expectedOrigin + '/')
861
+ || origin.startsWith(expectedOrigin2 + '/');
862
+ if (!originOk) {
863
+ res.writeHead(403, { 'Content-Type': 'application/json' });
864
+ res.end(JSON.stringify({ error: 'origin_not_allowed' }));
865
+ return true;
866
+ }
867
+ const rest = pathname.slice('/api/agents/'.length);
868
+ const [slug, action] = rest.split('/');
869
+ if (!slug || !/^[a-z0-9-]+$/i.test(slug) || !['retire', 'restore'].includes(action)) {
870
+ res.writeHead(400, { 'Content-Type': 'application/json' });
871
+ res.end(JSON.stringify({ error: 'invalid_request' }));
872
+ return true;
873
+ }
874
+ try {
875
+ const result = action === 'retire' ? retireAgent(slug) : restoreAgent(slug);
876
+ res.writeHead(result.ok ? 200 : 404, { 'Content-Type': 'application/json' });
877
+ res.end(JSON.stringify(result));
878
+ return true;
879
+ } catch (e) {
880
+ res.writeHead(500, { 'Content-Type': 'application/json' });
881
+ res.end(JSON.stringify({ error: e.message }));
882
+ return true;
883
+ }
884
+ }
885
+
886
+ // ── /api/push — Web Push subscription management ────────────────────────
887
+ // GET /api/push/vapid-key → { publicKey } (base64url, for browser subscribe)
888
+ // POST /api/push/subscribe → body: { endpoint, keys: { p256dh, auth } }
889
+ // DELETE /api/push/subscribe → body: { endpoint }
890
+ if (pathname === '/api/push/vapid-key' && req.method === 'GET') {
891
+ const keys = getVapidKeys(VAPID_KEYS_FILE);
892
+ res.writeHead(200, { 'Content-Type': 'application/json' });
893
+ res.end(JSON.stringify({ publicKey: keys.publicKey }));
894
+ return true;
895
+ }
896
+
897
+ if (pathname === '/api/push/subscribe' && req.method === 'POST') {
898
+ let body = '';
899
+ req.on('data', c => body += c);
900
+ req.on('end', () => {
901
+ try {
902
+ const sub = JSON.parse(body || '{}');
903
+ if (!sub.endpoint) { res.writeHead(400); return res.end(JSON.stringify({ error: 'endpoint_required' })); }
904
+ addSubscription(PUSH_SUBS_FILE, sub);
905
+ res.writeHead(201, { 'Content-Type': 'application/json' });
906
+ return res.end(JSON.stringify({ ok: true }));
907
+ } catch {
908
+ res.writeHead(400); return res.end(JSON.stringify({ error: 'invalid_json' }));
909
+ }
910
+ });
911
+ return true;
912
+ }
913
+
914
+ if (pathname === '/api/push/subscribe' && req.method === 'DELETE') {
915
+ let body = '';
916
+ req.on('data', c => body += c);
917
+ req.on('end', () => {
918
+ try {
919
+ const { endpoint } = JSON.parse(body || '{}');
920
+ if (!endpoint) { res.writeHead(400); return res.end(JSON.stringify({ error: 'endpoint_required' })); }
921
+ removeSubscription(PUSH_SUBS_FILE, endpoint);
922
+ res.writeHead(204);
923
+ return res.end();
924
+ } catch {
925
+ res.writeHead(400); return res.end(JSON.stringify({ error: 'invalid_json' }));
926
+ }
927
+ });
928
+ return true;
929
+ }
930
+
931
+ // ── /api/notif-history — in-app notification history ─────────────────────
932
+ // GET /api/notif-history?unread=1&limit=N → JSON array (newest first)
933
+ // POST /api/notif-history/read → body: { id? } (omit id = mark all read)
934
+ if (pathname === '/api/notif-history' && req.method === 'GET') {
935
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100);
936
+ const unreadOnly = url.searchParams.get('unread') === '1';
937
+ // Surface filter: the builder board must not see operate-side alerts (autopilot dead-letters,
938
+ // connector health, case SLA). Default to the builder surface; `?scope=operate|all` opts in.
939
+ // Classify by event name too, so entries written before the surface tag existed are filtered.
940
+ const scope = url.searchParams.get('scope') || 'builder';
941
+ const surfaceOf = (n) => n.surface || eventSurface(n.event);
942
+ let items = notifHistory;
943
+ if (scope === 'builder') items = items.filter(n => surfaceOf(n) !== 'operate');
944
+ else if (scope === 'operate') items = items.filter(n => surfaceOf(n) === 'operate');
945
+ items = items.slice(0, limit);
946
+ if (unreadOnly) items = items.filter(n => !n.read);
947
+ res.writeHead(200, { 'Content-Type': 'application/json' });
948
+ res.end(JSON.stringify(items));
949
+ return true;
950
+ }
951
+
952
+ if (pathname === '/api/notif-history/read' && req.method === 'POST') {
953
+ let body = '';
954
+ req.on('data', c => body += c);
955
+ req.on('end', () => {
956
+ try {
957
+ const { id } = JSON.parse(body || '{}');
958
+ if (id) {
959
+ const n = notifHistory.find(n => n.id === id);
960
+ if (n) n.read = true;
961
+ } else {
962
+ // Mark all read
963
+ for (const n of notifHistory) n.read = true;
964
+ }
965
+ saveNotifHistory();
966
+ res.writeHead(204);
967
+ return res.end();
968
+ } catch {
969
+ res.writeHead(400); return res.end(JSON.stringify({ error: 'invalid_json' }));
970
+ }
971
+ });
972
+ return true;
973
+ }
974
+
975
+ // ── Heartbeat watchdog (Paperclip pattern) ──────────────────────────────
976
+ // GET /api/heartbeat → { stuck: [{id,title,agent,age_h}], budgets: {agent→cap},
977
+ // goal_ancestry: string, tool_failure_rate_1h: number }
978
+ if (pathname === '/api/heartbeat') {
979
+ const tasks = getTasks(cwd);
980
+ const nowMs = Date.now();
981
+ const STUCK_H = 48;
982
+ const stuck = tasks
983
+ .filter(t => t.status === 'in_progress')
984
+ .map(t => {
985
+ const startedAt = t.startedAt ? new Date(t.startedAt).getTime() : null;
986
+ const ageH = startedAt ? (nowMs - startedAt) / 3600000 : null;
987
+ return { id: t.id, title: t.title, agent: t.agent, age_h: ageH ? Math.round(ageH) : null };
988
+ })
989
+ .filter(t => t.age_h !== null && t.age_h > STUCK_H);
990
+
991
+ // Per-agent budgets from PROJECT.md
992
+ const projectMdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
993
+ let budgets = {};
994
+ let goalAncestry = null;
995
+ try {
996
+ const projectTxt = fs.readFileSync(projectMdPath, 'utf8');
997
+ const sectionMatch = projectTxt.match(/^agent-budget:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
998
+ if (sectionMatch) {
999
+ for (const line of sectionMatch[1].split('\n')) {
1000
+ const m = line.match(/^\s+([a-z][a-z0-9-]*):\s*(\d+(?:\.\d+)?)/);
1001
+ if (m) budgets[m[1]] = parseFloat(m[2]);
1002
+ }
1003
+ }
1004
+ // Goal ancestry
1005
+ const archetype = (projectTxt.match(/^(?:archetype|primary):\s*(\S+)/m) || [])[1] || null;
1006
+ const compliance = (projectTxt.match(/^compliance:\s*(.+)$/m) || [])[1] || null;
1007
+ const phase = (projectTxt.match(/^phase:\s*(\S+)/m) || [])[1] || null;
1008
+ const complianceClean = compliance && !/^\[?none\]?$/i.test(compliance.trim()) ? compliance.trim() : null;
1009
+ if (archetype) goalAncestry = `[archetype:${archetype}]${complianceClean ? ` [compliance:${complianceClean}]` : ''}${phase ? ` [phase:${phase}]` : ''}`;
1010
+ } catch { /* project not found */ }
1011
+
1012
+ // Tool failure rate in last hour
1013
+ let toolFailureRate1h = 0;
1014
+ try {
1015
+ const failLog = fs.readFileSync(path.join(cwd, '.great_cto', 'tool-failures.log'), 'utf8');
1016
+ const cutoff = new Date(nowMs - 3600000).toISOString();
1017
+ toolFailureRate1h = failLog.split('\n').filter(l => l > cutoff && l.trim()).length;
1018
+ } catch { /* no log */ }
1019
+
1020
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1021
+ res.end(JSON.stringify({ stuck, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h }));
1022
+ return true;
1023
+ }
1024
+
1025
+ // API requests get a JSON 404 so frontends can JSON.parse() the response
1026
+ // without crashing. Static-file 404s stay plain text.
1027
+ if (pathname.startsWith('/api/')) {
1028
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1029
+ res.end(JSON.stringify({ error: 'Not found', path: pathname, hint: 'Endpoint missing — restart the board after a great-cto update.' }));
1030
+ return true;
1031
+ }
1032
+
1033
+ return false;
1034
+ }
1035
+
1036
+ export { dispatch };