great-cto 3.62.0 → 3.62.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.62.0",
4
+ "version": "3.62.1",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -0,0 +1,3 @@
1
+ // Fixed test-only parent, killed after the child-start handshake.
2
+ import { getTasksAsync } from '../lib/beads.mjs';
3
+ await getTasksAsync(process.cwd());
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Fixed local test protocol. No shell commands or operator stores.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ const root = process.env.FAKE_BD_CONTROL_ROOT;
7
+ if (!root || !path.isAbsolute(root)) process.exit(2);
8
+ const key = path.basename(process.cwd());
9
+ const marker = name => path.join(root, `${key}.${name}`);
10
+ if (process.argv.includes('--descendant')) {
11
+ process.on('SIGTERM', () => {});
12
+ fs.writeFileSync(marker('descendant'), String(process.pid));
13
+ setInterval(() => {}, 1000);
14
+ } else {
15
+ fs.appendFileSync(marker('started'), `${process.pid}\n`);
16
+ fs.writeFileSync(marker('worker'), String(process.ppid));
17
+ if (process.env.FAKE_BD_CONTROL_MODE === 'fail') {
18
+ process.exit(1);
19
+ } else if (['hang', 'leader-exit'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
20
+ process.on('SIGTERM', () => {});
21
+ spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'inherit' });
22
+ if (process.env.FAKE_BD_CONTROL_MODE === 'leader-exit') {
23
+ setInterval(() => { if (fs.existsSync(marker('descendant'))) process.exit(0); }, 25);
24
+ }
25
+ setInterval(() => {}, 1000);
26
+ } else if (process.env.FAKE_BD_CONTROL_MODE === 'large-success') {
27
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'x'.repeat(131072), status: 'open', priority: 0 }]), () => {
28
+ fs.writeFileSync(marker('output-written'), 'ready');process.exit(0);
29
+ });
30
+ } else if (['closed-success', 'closed-fail'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
31
+ const descendant = spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'ignore' });
32
+ descendant.unref();
33
+ const ready = setInterval(() => {
34
+ if (!fs.existsSync(marker('descendant'))) return;
35
+ clearInterval(ready);
36
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]),
37
+ () => process.exit(process.env.FAKE_BD_CONTROL_MODE === 'closed-fail' ? 1 : 0));
38
+ }, 25);
39
+ } else if (process.env.FAKE_BD_CONTROL_MODE === 'oversize') {
40
+ process.stdout.write(Buffer.alloc(16 * 1024 * 1024 + 1, 120));
41
+ setInterval(() => {}, 1000);
42
+ } else {
43
+ const timer = setInterval(() => {
44
+ if (!fs.existsSync(marker('release'))) return;
45
+ clearInterval(timer);
46
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]));
47
+ }, 25);
48
+ }
49
+ }
@@ -27,7 +27,7 @@ import { getCostHistory, getInbox } from './data-readers.mjs';
27
27
  // reporting "live · synced just now". `sweep: true` must NEVER be added to
28
28
  // an interactive read's opts: an ordinary request is someone waiting on
29
29
  // THIS project's answer, and the fill path answers `[]` instead.
30
- import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
30
+ import { getTasks, getTasksAsync, getReadDegradation, SWEEP_MAX_AGE_MS } from './beads.mjs';
31
31
  const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
32
32
  import { readVerdicts } from './verdicts.mjs';
33
33
  import { isFailure } from './fleet.mjs';
@@ -303,11 +303,16 @@ function startAlertCron() {
303
303
  }, FIVE_MIN);
304
304
 
305
305
  // cost.threshold: monthly LLM spend at 80% / 100% of budget
306
- setInterval(() => {
306
+ setInterval(async () => {
307
307
  try {
308
308
  const projects = listProjects();
309
309
  for (const proj of projects) {
310
- const m = getMetrics(proj.path);
310
+ let tasks;
311
+ try {
312
+ tasks = await getTasksAsync(proj.path);
313
+ if (getReadDegradation(proj.path)) continue;
314
+ } catch { continue; } // Unknown spend must not be treated as zero.
315
+ const m = getMetrics(proj.path, 30, { tasks });
311
316
  const meta = readProjectMd(proj.path) || {};
312
317
  const budget = parseFloat(meta['monthly-budget']?.replace?.(/[$\s]/g, '') || '0');
313
318
  if (!budget) continue;
@@ -356,7 +361,12 @@ function startAlertCron() {
356
361
  // next project reads alerts-fired.json. Prevents the race condition where
357
362
  // all N projects read an empty map simultaneously and all fire.
358
363
  for (const proj of projects) {
359
- const m = getMetrics(proj.path);
364
+ let tasks;
365
+ try {
366
+ tasks = await getTasksAsync(proj.path);
367
+ if (getReadDegradation(proj.path)) continue;
368
+ } catch { continue; } // Unknown is not a zero-activity weekly report.
369
+ const m = getMetrics(proj.path, 30, { tasks });
360
370
  const dedupeKey = `digest.weekly:${proj.slug}:${isoWeek}`;
361
371
  const weeklyPayload = {
362
372
  title: `${proj.slug} weekly — ${m.tasks?.done || 0} shipped, $${(m.cost?.llm_usd || 0).toFixed(2)} spent`,
@@ -395,18 +405,22 @@ function startAlertCron() {
395
405
  const projects = listProjects();
396
406
  for (const proj of projects) {
397
407
  const dedupeKey = `digest.daily:${proj.slug}:${isoDay}`;
408
+ let tasks;
409
+ try {
410
+ tasks = await getTasksAsync(proj.path);
411
+ if (getReadDegradation(proj.path)) continue;
412
+ } catch { continue; } // Do not send or record a fabricated quiet day.
398
413
  // Pull last 2 days so "yesterday" is always in the window
399
- const cost = getCostHistory(proj.path, 2);
414
+ const cost = getCostHistory(proj.path, 2, { tasks });
400
415
  const yBucket = (cost.series || []).find(s => s.date === yesterday);
401
416
  const ySpend = yBucket ? yBucket.llm : 0;
402
417
  // Feature breakdown for yesterday
403
418
  const topFeatures = (cost.by_feature || []).slice(0, 3);
404
419
  // Inbox for blocked + gates
405
- const inbox = (() => { try { return getInbox(proj.path); } catch { return {}; } })();
420
+ const inbox = getInbox(proj.path, { tasks });
406
421
  const blocked = inbox.summary?.blocked ?? 0;
407
422
  const gates = inbox.summary?.gates ?? 0;
408
423
  // Tasks closed yesterday
409
- const tasks = (() => { try { return getTasks(proj.path, SWEEP); } catch { return []; } })();
410
424
  const doneYesterday = tasks.filter(t => {
411
425
  if (!t.closed_at) return false;
412
426
  return t.closed_at.slice(0, 10) === yesterday;
@@ -0,0 +1,64 @@
1
+ // A read's process-group lifetime must not depend on the HTTP event loop.
2
+ // Forked into a newly owned group; ordinary module imports do not start work.
3
+ import { fork, spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ const entry = fileURLToPath(import.meta.url);
6
+ export function spawnBdRead(binary, { readDeadlineMs = 20250, ...options }) {
7
+ return fork(entry, [binary, String(readDeadlineMs)], { ...options, silent: true });
8
+ }
9
+ if (process.argv[1] === entry) {
10
+ const grouped = process.platform !== 'win32';
11
+ let child;
12
+ function killOwnedGroup() {
13
+ // POSIX fork is detached: the worker PID is exclusively this read's PGID.
14
+ if (grouped) {
15
+ try { process.kill(-process.pid, 'SIGKILL'); } catch { process.exit(1); }
16
+ } else {
17
+ try { child?.kill('SIGKILL'); } catch { /* already gone */ }
18
+ process.exit(1);
19
+ }
20
+ }
21
+ process.on('disconnect', killOwnedGroup);
22
+ process.on('SIGTERM', () => {}); // keep the independent KILL deadline alive
23
+ process.stdout.on('error', killOwnedGroup);
24
+ process.stderr.on('error', killOwnedGroup);
25
+ // Independent upper bound even if the parent's event loop is stalled.
26
+ const budget = Number(process.argv[3]);
27
+ if (!Number.isFinite(budget) || budget < 1 || budget > 20250) process.exit(2);
28
+ setTimeout(killOwnedGroup, budget);
29
+ let completing = false;
30
+ function complete(code) {
31
+ if (completing) return;
32
+ completing = true;
33
+ // A successful leader can leave descendants with closed/ignored pipes.
34
+ // Flush the result over IPC before killing the whole owned group. Keep
35
+ // the independent deadline alive until cleanup, even if IPC stalls.
36
+ if (!process.connected) return killOwnedGroup();
37
+ // child 'close' means its pipes ended, not that this worker's output
38
+ // queues reached the parent. Flush before announcing success or KILL
39
+ // can truncate an otherwise valid snapshot under pipe backpressure.
40
+ try { process.stdout.write('', stdoutError => {
41
+ if (stdoutError) return killOwnedGroup();
42
+ process.stderr.write('', stderrError => {
43
+ if (stderrError || !process.connected) return killOwnedGroup();
44
+ try { process.send({ type: 'bd-read-result', code }, () => killOwnedGroup()); }
45
+ catch { killOwnedGroup(); }
46
+ });
47
+ }); }
48
+ catch { killOwnedGroup(); }
49
+ }
50
+ try {
51
+ child = spawn(process.argv[2], ['list', '--json', '--all', '--include-gates'], {
52
+ stdio: ['ignore', 'pipe', 'pipe']
53
+ });
54
+ child.stdout.pipe(process.stdout, { end: false });
55
+ child.stderr.pipe(process.stderr, { end: false });
56
+ child.on('error', () => complete(1));
57
+ child.on('close', code => {
58
+ // No background descendants are part of a completed read contract.
59
+ // Wait for inherited pipes too: exiting on leader 'exit' would remove
60
+ // the IPC watchdog while a descendant was still holding those pipes.
61
+ complete(Number.isInteger(code) ? code : 1);
62
+ });
63
+ } catch { complete(1); }
64
+ }
@@ -5,6 +5,7 @@ import { spawnSync, spawn } from 'child_process';
5
5
  import { readSafe } from './util.mjs';
6
6
  import { bdCache } from './state.mjs';
7
7
  import { log } from './log.mjs';
8
+ import { spawnBdRead } from './bd-read-worker.mjs';
8
9
 
9
10
  // ── Beads data ─────────────────────────────────────────────────────────────────
10
11
  // Cache bdList output per cwd for BD_CACHE_TTL_MS. Invalidated when the project's
@@ -39,13 +40,11 @@ import { log } from './log.mjs';
39
40
  const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
40
41
 
41
42
  /**
42
- * When a cwd was last told "your cache entry is wrong" — by an invalidate or
43
- * a stale-mark, whichever most recently ran. A background fetch that STARTED
44
- * before this timestamp answers a question nobody is asking anymore: see
45
- * `bdRefreshAsync`, which compares its own start time against this map before
46
- * committing its result, so a slow fetch racing a write always loses to it.
43
+ * Per-project invalidation generation. A background fetch may publish only
44
+ * when its generation is still current. Wall-clock timestamps cannot order
45
+ * an invalidate followed by a read within the same millisecond.
47
46
  */
48
- const lastInvalidatedAt = new Map();
47
+ const invalidationGeneration = new Map();
49
48
 
50
49
  /**
51
50
  * Drop a directory's entry so the next read is guaranteed fresh.
@@ -56,7 +55,7 @@ const lastInvalidatedAt = new Map();
56
55
  */
57
56
  function bdCacheInvalidate(cwd) {
58
57
  clearSelfTouch(cwd);
59
- lastInvalidatedAt.set(cwd, Date.now());
58
+ invalidationGeneration.set(cwd, (invalidationGeneration.get(cwd) || 0) + 1);
60
59
  bdCache.delete(cwd);
61
60
  }
62
61
 
@@ -73,7 +72,7 @@ function bdCacheInvalidate(cwd) {
73
72
  */
74
73
  function bdCacheStale(cwd) {
75
74
  clearSelfTouch(cwd);
76
- lastInvalidatedAt.set(cwd, Date.now());
75
+ invalidationGeneration.set(cwd, (invalidationGeneration.get(cwd) || 0) + 1);
77
76
  const cached = bdCache.get(cwd);
78
77
  if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
79
78
  }
@@ -297,6 +296,17 @@ const EMPTY_TTL_MS = Number(process.env.GREAT_CTO_BD_EMPTY_TTL_MS || 5000);
297
296
  * Without this, ten requests arriving while one refresh runs start ten more.
298
297
  */
299
298
  const refreshing = new Set();
299
+ const refreshWaiters = new Map();
300
+ const refreshQueue = [];
301
+ let activeReads = 0;
302
+ const READ_TIMEOUT_MS = 20000;
303
+ const READ_MAX_BYTES = 16 * 1024 * 1024;
304
+ function drainRefreshQueue() {
305
+ while (activeReads < 4 && refreshQueue.length) {
306
+ activeReads++;
307
+ refreshQueue.shift()();
308
+ }
309
+ }
300
310
 
301
311
  /**
302
312
  * Refresh a directory's entry WITHOUT holding the event loop.
@@ -304,44 +314,108 @@ const refreshing = new Set();
304
314
  * `spawnSync` is what made this board unanswerable: `bd list` costs seconds and
305
315
  * blocks everything for the whole of it — /api/version, one readdirSync,
306
316
  * measured at 1-10 s because it was queued behind a task read. Warming at boot
307
- * moved the first stall out of sight; this removes the rest.
317
+ * moved the first stall out of sight; HTTP cold reads now await this path.
318
+ * Direct synchronous write broadcasts and background readers remain separate.
308
319
  *
309
- * `onDone`, if given, fires exactly once at every exit point (success,
310
- * failure, or "already refreshing"). Every existing caller omits it and stays
311
- * fire-and-forget — the ONE caller that wants to know when the fill finished
312
- * is the boot warm-up (see `warmTasksAsync`), which logs how long it took.
320
+ * `onDone`, if given, fires exactly once when the shared read finishes. A
321
+ * concurrent reader joins it rather than receiving a premature "skipped".
322
+ * Background callers omit it and stay fire-and-forget. Boot warm-up and
323
+ * interactive cold reads join the same completion through `warmTasksAsync`.
313
324
  */
314
- function bdRefreshAsync(cwd, onDone = () => {}) {
315
- if (refreshing.has(cwd)) { onDone({ ok: false, skipped: true }); return; }
325
+ function bdRefreshAsync(cwd, onDone = null) {
326
+ if (refreshing.has(cwd)) { if (onDone) refreshWaiters.get(cwd).push(onDone); return; }
316
327
  refreshing.add(cwd);
328
+ refreshWaiters.set(cwd, onDone ? [onDone] : []);
329
+ const due = performance.now() + READ_TIMEOUT_MS;
330
+ const run = () => {
331
+ clearTimeout(queuedDeadline);
332
+ startRefresh(cwd, Math.max(1, due - performance.now()));
333
+ };
334
+ const queuedDeadline = setTimeout(() => {
335
+ const index = refreshQueue.indexOf(run);
336
+ if (index < 0) return;
337
+ refreshQueue.splice(index, 1);
338
+ refreshing.delete(cwd);
339
+ bdFailures.set(cwd, 'bd read timed out waiting for a slot');
340
+ const waiters = refreshWaiters.get(cwd) || [];
341
+ refreshWaiters.delete(cwd);
342
+ for (const done of waiters) done({ ok: false });
343
+ }, READ_TIMEOUT_MS);
344
+ refreshQueue.push(run);
345
+ drainRefreshQueue();
346
+ }
347
+
348
+ function startRefresh(cwd, remainingMs) {
317
349
  const startedAt = Date.now();
350
+ const generation = invalidationGeneration.get(cwd) || 0;
318
351
  lastBdRunAt.set(cwd, startedAt);
319
- let out = '';
352
+ let out = '', outBytes = 0;
353
+ let child, deadline, killDeadline, finished = false, workerResult;
354
+ const grouped = process.platform !== 'win32';
355
+ function signalOwned(signal) {
356
+ if (!child?.pid) return;
357
+ try { if (grouped) process.kill(-child.pid, signal); else child.kill(signal); } catch { /* already exited */ }
358
+ }
359
+ function finish(result) {
360
+ if (finished) return;
361
+ finished = true;
362
+ clearTimeout(deadline); clearTimeout(killDeadline);
363
+ refreshing.delete(cwd);
364
+ const waiters = refreshWaiters.get(cwd) || [];
365
+ refreshWaiters.delete(cwd);
366
+ activeReads--;
367
+ for (const done of waiters) done(result);
368
+ drainRefreshQueue();
369
+ }
370
+ function stop(reason) {
371
+ if (finished || killDeadline) return;
372
+ bdFailures.set(cwd, reason);
373
+ signalOwned('SIGTERM');
374
+ // TERM alone can leave a read and inherited pipes alive indefinitely.
375
+ killDeadline = setTimeout(() => {
376
+ signalOwned('SIGKILL');
377
+ child.stdout?.destroy(); child.stderr?.destroy();
378
+ finish({ ok: false });
379
+ }, 250);
380
+ }
320
381
  try {
321
- // Bounded like every other bd call. Without a timeout a hung bd never fires
322
- // 'close', so `refreshing` stays held for this cwd forever and the cache
323
- // silently serves stale tasks with no error anywhere — the async twin of the
324
- // sync path's 500s. Node kills on timeout, which DOES fire 'close' with a
325
- // non-zero code, so the failure handling below reports it rather than the
326
- // board simply going quiet.
327
- const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv(), timeout: 20000 });
328
- child.stdout?.on('data', (d) => { out += d; });
382
+ // At most four reads run concurrently. The fixed deadline escalates TERM
383
+ // to KILL for this newly owned process group; Node's TERM-only timeout did
384
+ // not release the slot when a child ignored it or inherited pipes stayed open.
385
+ child = spawnBdRead(BD_BIN, { cwd, env: bdEnv(), detached: grouped, readDeadlineMs: Math.ceil(remainingMs) + 250 });
386
+ child.on('message', message => {
387
+ if (message?.type === 'bd-read-result' && Number.isInteger(message.code)) workerResult = message.code;
388
+ });
389
+ deadline = setTimeout(() => stop('bd read timed out'), remainingMs);
390
+ child.stdout?.setEncoding('utf8');
391
+ child.stdout?.on('data', (d) => {
392
+ if (finished || killDeadline) return;
393
+ outBytes += Buffer.byteLength(d);
394
+ if (outBytes > READ_MAX_BYTES) { stop('bd output exceeded limit'); return; }
395
+ out += d;
396
+ });
397
+ // Drain stderr without retaining arbitrary private subprocess output.
398
+ child.stderr?.resume();
329
399
  child.on('error', (e) => {
330
- refreshing.delete(cwd);
331
400
  bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
332
- onDone({ ok: false });
401
+ finish({ ok: false });
333
402
  });
334
403
  child.on('close', (code) => {
335
- refreshing.delete(cwd);
404
+ if (finished) return;
405
+ if (killDeadline) { signalOwned('SIGKILL'); finish({ ok: false }); return; }
406
+ // The worker reports its leader result, then terminates its entire
407
+ // owned group, including itself. A bare signal without that result
408
+ // remains a failed read, never a fabricated successful empty list.
409
+ code = workerResult ?? code;
336
410
  lastBdRunAt.set(cwd, Date.now());
337
- if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); onDone({ ok: false }); return; }
411
+ if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); finish({ ok: false }); return; }
338
412
  try {
339
413
  const parsed = JSON.parse(out || '[]');
340
414
  // Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
341
415
  // and a non-array rendered as no tasks is the silent zero again.
342
416
  if (!Array.isArray(parsed)) {
343
417
  bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
344
- onDone({ ok: false });
418
+ finish({ ok: false });
345
419
  return;
346
420
  }
347
421
  // This fetch answers "what was true at `startedAt`?" — if anyone has
@@ -353,24 +427,23 @@ function bdRefreshAsync(cwd, onDone = () => {}) {
353
427
  // `tests/pipeline-e2e.test.mjs`'s gate-approval assertions flake once
354
428
  // this function started running from a COLD cache (the boot warm-up)
355
429
  // instead of only ever refreshing an already-populated one.
356
- const supersededByInvalidation = (lastInvalidatedAt.get(cwd) || 0) >= startedAt;
430
+ const supersededByInvalidation = (invalidationGeneration.get(cwd) || 0) !== generation;
357
431
  const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
358
432
  if (supersededByInvalidation || supersededByFresherEntry) {
359
- onDone({ ok: true, count: parsed.length, discarded: true });
433
+ finish({ ok: true, count: parsed.length, discarded: true });
360
434
  return;
361
435
  }
362
436
  bdFailures.delete(cwd);
363
437
  bdCache.set(cwd, { ts: Date.now(), data: parsed });
364
- onDone({ ok: true, count: parsed.length });
438
+ finish({ ok: true, count: parsed.length });
365
439
  } catch (e) {
366
440
  bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
367
- onDone({ ok: false });
441
+ finish({ ok: false });
368
442
  }
369
443
  });
370
444
  } catch (e) {
371
- refreshing.delete(cwd);
372
445
  bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
373
- onDone({ ok: false });
446
+ finish({ ok: false });
374
447
  }
375
448
  }
376
449
 
@@ -410,6 +483,34 @@ function warmTasksAsync(cwd) {
410
483
  });
411
484
  }
412
485
 
486
+ /** Wait for an actual cold snapshot without blocking Node or inventing []. */
487
+ async function getTasksAsync(cwd = process.cwd()) {
488
+ if (!bdCache.has(cwd)) {
489
+ const result = await warmTasksAsync(cwd);
490
+ if (!bdCache.has(cwd)) {
491
+ // tasks.md-only projects remain supported, but never hide a failed
492
+ // initialized Beads store or a snapshot invalidated during this read.
493
+ if (!result.ok && checkBeadsAvailable(cwd)) {
494
+ bdCache.set(cwd, { ts: Date.now(), data: [] });
495
+ return getTasks(cwd);
496
+ }
497
+ throw new Error(bdFailureFor(cwd) || 'Task snapshot changed while being read; retry');
498
+ }
499
+ }
500
+ return getTasks(cwd);
501
+ }
502
+
503
+ /** Other-project badges may be incomplete, but must never block HTTP. */
504
+ function getTasksCached(cwd = process.cwd()) {
505
+ if (!bdCache.has(cwd)) {
506
+ bdRefreshAsync(cwd);
507
+ throw new Error('Task snapshot is loading');
508
+ }
509
+ const tasks = getTasks(cwd);
510
+ if (getReadDegradation(cwd)) throw new Error('Task snapshot is unavailable');
511
+ return tasks;
512
+ }
513
+
413
514
  function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
414
515
  const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
415
516
  const cached = bdCache.get(cwd);
@@ -864,6 +965,8 @@ export {
864
965
  getReadDegradation,
865
966
  setTaskStatusInTasksMd,
866
967
  getTasks,
968
+ getTasksAsync,
969
+ getTasksCached,
867
970
  mapStatus,
868
971
  detectAgent,
869
972
  };
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { GREAT_CTO_DIR } from './config.mjs';
5
5
  import { readFileSafe } from './util.mjs';
6
- import { getTasks } from './beads.mjs';
6
+ import { getTasks, getTasksAsync, getTasksCached } from './beads.mjs';
7
7
  import { readVerdicts, readSecStats } from './verdicts.mjs';
8
8
  import { datePlans } from './plan-date.mjs';
9
9
 
@@ -73,7 +73,7 @@ function getMemory(cwd = process.cwd()) {
73
73
  }
74
74
 
75
75
  // ── Pipeline state ────────────────────────────────────────────────────────────
76
- function getPipeline(cwd = process.cwd()) {
76
+ function getPipeline(cwd = process.cwd(), { tasks = getTasks(cwd) } = {}) {
77
77
  const stages = ['product-owner', 'architect', 'pm', 'senior-dev', 'reviewers', 'qa-engineer', 'security-officer', 'devops', 'l3-support'];
78
78
  const verdicts = readVerdicts(cwd);
79
79
  const now = Date.now();
@@ -87,7 +87,6 @@ function getPipeline(cwd = process.cwd()) {
87
87
  }
88
88
 
89
89
  // Tasks in_progress give us "active" agents
90
- const tasks = getTasks(cwd);
91
90
  const activeAgents = new Set(
92
91
  tasks.filter(t => t.status === 'in_progress').map(t => (t.agent || '').toLowerCase()).filter(Boolean)
93
92
  );
@@ -179,7 +178,7 @@ function getPipeline(cwd = process.cwd()) {
179
178
  }
180
179
 
181
180
  // ── Cost history (daily LLM burn) ────────────────────────────────────────────
182
- function getCostHistory(cwd = process.cwd(), days = 30) {
181
+ function getCostHistory(cwd = process.cwd(), days = 30, { tasks: suppliedTasks } = {}) {
183
182
  // Build a map<dateISO, { llm, human, plans, verdictCost }>.
184
183
  // Inclusive window: `days=30` means [today-30 ... today] = 31 buckets.
185
184
  // Previous behaviour (days buckets only) created a one-day gap on the
@@ -315,7 +314,7 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
315
314
  const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
316
315
  const DEFAULT_TASK_MIN = 30;
317
316
  try {
318
- const tasks = getTasks(cwd);
317
+ const tasks = suppliedTasks ?? getTasks(cwd);
319
318
  for (const t of tasks) {
320
319
  if (!t.closed_at) continue;
321
320
  if (!t.agent) continue;
@@ -401,8 +400,7 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
401
400
  }
402
401
 
403
402
  // ── Inbox: what needs the user's decision right now ──────────────────────────
404
- function getInbox(cwd = process.cwd()) {
405
- const tasks = getTasks(cwd);
403
+ function getInbox(cwd = process.cwd(), { tasks = getTasks(cwd) } = {}) {
406
404
  // Use raw_status here: mapStatus() rewrites status to 'gate' for any task with the
407
405
  // 'gate' label, regardless of bd-native state. Filtering on the mapped value would
408
406
  // leave closed/blocked gates in the inbox forever.
@@ -502,7 +500,7 @@ function getInbox(cwd = process.cwd()) {
502
500
  * `readInbox` is injectable so the walk can be tested with stub projects
503
501
  * without a beads store behind each one.
504
502
  */
505
- function inboxElsewhere(projects, currentPath, { readInbox = getInbox } = {}) {
503
+ function inboxElsewhere(projects, currentPath, { readInbox = getInboxCached } = {}) {
506
504
  const same = (a, b) => {
507
505
  if (!a || !b) return false;
508
506
  try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); }
@@ -521,4 +519,6 @@ function inboxElsewhere(projects, currentPath, { readInbox = getInbox } = {}) {
521
519
  return out;
522
520
  }
523
521
 
524
- export { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere };
522
+ async function getInboxAsync(cwd) { return getInbox(cwd, { tasks: await getTasksAsync(cwd) }); }
523
+ function getInboxCached(cwd) { return getInbox(cwd, { tasks: getTasksCached(cwd) }); }
524
+ export { getMemory, getPipeline, getCostHistory, getInbox, getInboxAsync, getInboxCached, inboxElsewhere };
@@ -43,11 +43,10 @@ function acceptanceMetrics(verdicts = [], windowCostUsd = null) {
43
43
  }
44
44
 
45
45
  // ── Metrics ────────────────────────────────────────────────────────────────────
46
- function getMetrics(cwd = process.cwd(), days = 30) {
46
+ function getMetrics(cwd = process.cwd(), days = 30, { tasks = getTasks(cwd) } = {}) {
47
47
  // `days` controls the window for cost/agents_cost and for "shipped in window".
48
48
  // Tasks shipped within the window are returned in `tasks.done_in_window`.
49
49
  // Full lifetime `tasks.done` is still returned for backwards compatibility.
50
- const tasks = getTasks(cwd);
51
50
  // BH-28: resolved gates (approve→closed, reject→blocked) get mapped to
52
51
  // status='done' by mapStatus(). Those are governance decisions, not shipped
53
52
  // features — counting them inflates velocity / tasks-shipped on the report.
@@ -27,9 +27,9 @@ import { status as routerKeyStatus, writeKey as writeRouterKey, verifyKey as ver
27
27
  let routerKeyCheck = null;
28
28
  import { broadcastTasks } from './sse.mjs';
29
29
  import { saveNotifHistory } from './notifications.mjs';
30
- import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from './data-readers.mjs';
30
+ import { getMemory, getPipeline, getCostHistory, getInbox, getInboxAsync, inboxElsewhere } from './data-readers.mjs';
31
31
  import { log } from './log.mjs';
32
- import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
32
+ import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, getTasksAsync, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
33
33
  import { getMetrics } from './metrics.mjs';
34
34
  import { agentActivity, agentActivitySince } from './agent-activity.mjs';
35
35
  import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
@@ -124,6 +124,32 @@ async function dispatch(req, res, url, cwd) {
124
124
  const scopedProject = ['/api/skills', '/api/usage', '/api/outcomes'].includes(pathname)
125
125
  ? scopedReadProject(cwd, serverCwd, requestedProject) : null;
126
126
 
127
+ // Existing synchronous projections may consume a completed snapshot, but
128
+ // HTTP cold reads must await async I/O instead of freezing unrelated clients.
129
+ const taskRead = ['/api/work', '/api/sse', '/api/tasks', '/api/metrics',
130
+ '/api/memory', '/api/pipeline', '/api/heartbeat', '/api/share', '/api/cost', '/api/resume'].includes(pathname)
131
+ || /^\/api\/tasks\/[^/]+\/history$/.test(pathname);
132
+ const unknownWork = pathname === '/api/work' && requestedProject
133
+ && resolveProjectInfo(requestedProject).resolved === 'fallback';
134
+ let sseClosed = false;
135
+ if (pathname === '/api/sse') {
136
+ // Install lifecycle ownership before the cold snapshot await. Request
137
+ // 'close' also occurs for normally completed GET bodies; response close
138
+ // is the actual stream lifetime signal.
139
+ res.once('close', () => { sseClosed = true; sseClients.delete(res); });
140
+ }
141
+ const closedSse = () => pathname === '/api/sse'
142
+ && (sseClosed || req.aborted || res.destroyed || res.writableEnded);
143
+ if (req.method === 'GET' && taskRead && !unknownWork) {
144
+ try { await getTasksAsync(cwd); }
145
+ catch {
146
+ if (closedSse()) return true;
147
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
148
+ res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
149
+ return true;
150
+ }
151
+ }
152
+
127
153
  if (pathname === '/api/work/publication') {
128
154
  const respond = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
129
155
  if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') { respond(404, { error: 'Unknown project' }); return true; }
@@ -222,6 +248,7 @@ async function dispatch(req, res, url, cwd) {
222
248
 
223
249
  // SSE
224
250
  if (pathname === '/api/sse') {
251
+ if (closedSse()) return true;
225
252
  res.writeHead(200, {
226
253
  'Content-Type': 'text/event-stream',
227
254
  'Cache-Control': 'no-cache',
@@ -229,7 +256,11 @@ async function dispatch(req, res, url, cwd) {
229
256
  });
230
257
  res._gctoCwd = cwd; // remember which project this client wants
231
258
  sseClients.add(res);
232
- res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
259
+ if (getReadDegradation(cwd)) {
260
+ res.write(`event: task-read-state\ndata: ${JSON.stringify({ ok: false, state: 'unreadable' })}\n\n`);
261
+ } else {
262
+ res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
263
+ }
233
264
  // ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
234
265
  // Phase 2: a client that hands back the cursor of the last frame it saw — the
235
266
  // Last-Event-ID header, or ?since= from a page that had to recreate its
@@ -243,7 +274,6 @@ async function dispatch(req, res, url, cwd) {
243
274
  }
244
275
  res._gctoAgentCursor = a.cursor;
245
276
  res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
246
- req.on('close', () => sseClients.delete(res));
247
277
  return true;
248
278
  }
249
279
 
@@ -718,7 +748,7 @@ async function dispatch(req, res, url, cwd) {
718
748
  // Without this, concurrent approve+reject on the same gate produced
719
749
  // TWO appendDecisionLog entries (one wrong) — log says approved AND
720
750
  // rejected. bdWriteSerialised guarantees one-at-a-time semantics.
721
- const result = await bdWriteSerialised(() => {
751
+ const result = await bdWriteSerialised(async () => {
722
752
  const status = action === 'approve' ? 'closed' : 'blocked';
723
753
  let via = 'beads';
724
754
  // Try beads first (unless there's no store at all); on any bd failure
@@ -761,7 +791,7 @@ async function dispatch(req, res, url, cwd) {
761
791
  const projectSlug = parsed.project && !/^[\/~]/.test(parsed.project) ? parsed.project : path.basename(gateCwd);
762
792
  let title = id;
763
793
  try {
764
- const gateTask = getTasks(gateCwd).find((t) => t.id === id);
794
+ const gateTask = (await getTasksAsync(gateCwd)).find((t) => t.id === id);
765
795
  if (gateTask?.title) title = gateTask.title;
766
796
  } catch { /* the id is a documented fallback; the record still gets written */ }
767
797
  gateTitle = title;
@@ -847,7 +877,13 @@ async function dispatch(req, res, url, cwd) {
847
877
 
848
878
  // Inbox — what needs your attention right now
849
879
  if (pathname === '/api/inbox') {
850
- const inbox = getInbox(cwd);
880
+ let inbox;
881
+ try { inbox = await getInboxAsync(cwd); }
882
+ catch {
883
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
884
+ res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
885
+ return true;
886
+ }
851
887
  // BRD-R3: the Decisions row shows both reviewers. The second opinion is a
852
888
  // fact about the TREE, not about a gate — every pending gate on this tree
853
889
  // shares it — so it is resolved once: the newest cross-review line whose
@@ -861,7 +897,7 @@ async function dispatch(req, res, url, cwd) {
861
897
  // If the registry itself cannot be walked, say so — `unreadable` is not
862
898
  // `{p0: 0}`, and the page must not print "nothing elsewhere" from it.
863
899
  let elsewhere;
864
- try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
900
+ try { elsewhere = inboxElsewhere(listProjects(), cwd); }
865
901
  catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
866
902
  // ADR-024 §1: each pending gate carries the token an approval must present,
867
903
  // bound to the project as it is now. The typed name stored with it is the one
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { GREAT_CTO_DIR, PUBLIC, SHARE_ENDPOINT } from './config.mjs';
4
4
  import { readProjectMd } from './projects.mjs';
5
5
  import { getMetrics } from './metrics.mjs';
6
- import { getTasks } from './beads.mjs';
6
+ import { getTasks, getTasksAsync, getReadDegradation } from './beads.mjs';
7
7
  import { readVerdicts } from './verdicts.mjs';
8
8
  import { readDecisionsLog } from './fleet.mjs';
9
9
 
@@ -87,8 +87,10 @@ async function toggleShare(enable, cwd = process.cwd(), force = false) {
87
87
  // Generate and publish
88
88
  // Share report is a marketing artifact — show LIFETIME numbers, not a
89
89
  // rolling window. 365 days × 100 = effectively-lifetime cap for any project.
90
- const html = generateShareHTML(getTasks(cwd), getMetrics(cwd, 36500), cwd);
91
90
  try {
91
+ const tasks = await getTasksAsync(cwd);
92
+ if (getReadDegradation(cwd)) return { error: 'Task snapshot is unavailable; report not published' };
93
+ const html = generateShareHTML(tasks, getMetrics(cwd, 36500, { tasks }), cwd);
92
94
  const result = await publishReport(html);
93
95
  const newState = { enabled: true, url: result.url, hash: result.hash, published_at: new Date().toISOString() };
94
96
  saveShareState(newState, cwd);
@@ -1,5 +1,6 @@
1
1
  import { sseClients } from './state.mjs';
2
- import { getTasks } from './beads.mjs';
2
+ import { getTasksAsync, getReadDegradation, warmTasksAsync, checkBeadsAvailable } from './beads.mjs';
3
+ import { getPipeline, getInbox } from './data-readers.mjs';
3
4
 
4
5
  function broadcast(event, data) {
5
6
  const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -8,13 +9,41 @@ function broadcast(event, data) {
8
9
  }
9
10
  }
10
11
 
11
- function broadcastTasks(cwd) {
12
- const msg = `event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`;
13
- for (const res of sseClients) {
14
- if (res._gctoCwd === cwd) {
12
+ async function broadcastTasks(cwd, { fresh = false, projections = false } = {}) {
13
+ // A completed write invalidates its cache. Reading it synchronously here
14
+ // froze every HTTP client even though the write response was already sent.
15
+ // With no subscriber there is no projection to refresh at all.
16
+ for (const res of sseClients) if (res.destroyed || res.writableEnded) sseClients.delete(res);
17
+ const clients = [...sseClients].filter(res => res._gctoCwd === cwd);
18
+ if (!clients.length) return { ok: true, subscribers: 0 };
19
+ let msg, result;
20
+ try {
21
+ if (fresh && !checkBeadsAvailable(cwd)) {
22
+ const read = await warmTasksAsync(cwd);
23
+ if (!read.ok || read.discarded) throw Error('fresh snapshot unavailable');
24
+ }
25
+ const tasks = await getTasksAsync(cwd);
26
+ if (getReadDegradation(cwd)) throw Error('snapshot unavailable');
27
+ msg = `event: tasks\ndata: ${JSON.stringify(tasks)}\n\n`;
28
+ if (projections) {
29
+ // All projections describe the same snapshot, without another Beads read.
30
+ msg += `event: pipeline\ndata: ${JSON.stringify(getPipeline(cwd, { tasks }))}\n\n`;
31
+ msg += `event: inbox\ndata: ${JSON.stringify(getInbox(cwd, { tasks }))}\n\n`;
32
+ }
33
+ result = { ok: true };
34
+ } catch {
35
+ // Preserve the last visible tasks instead of publishing a fabricated [].
36
+ // Explicit transport state; clients retain the last visible snapshot.
37
+ result = { ok: false, state: 'unreadable' };
38
+ msg = `event: task-read-state\ndata: ${JSON.stringify(result)}\n\n`;
39
+ }
40
+ for (const res of clients) {
41
+ // A client may have disconnected or changed project while the read ran.
42
+ if (sseClients.has(res) && res._gctoCwd === cwd && !res.destroyed && !res.writableEnded) {
15
43
  try { res.write(msg); } catch { sseClients.delete(res); }
16
44
  }
17
45
  }
46
+ return result;
18
47
  }
19
48
 
20
49
  export { broadcast, broadcastTasks };
@@ -3,8 +3,8 @@ import path from 'path';
3
3
  import { GREAT_CTO_DIR } from './config.mjs';
4
4
  import { sseClients } from './state.mjs';
5
5
  import { listProjects } from './projects.mjs';
6
- import { bdCacheStale, getTasks, isSelfInflictedTouch } from './beads.mjs';
7
- import { getPipeline, getInbox } from './data-readers.mjs';
6
+ import { bdCacheStale, isSelfInflictedTouch } from './beads.mjs';
7
+ import { broadcastTasks } from './sse.mjs';
8
8
  import { agentActivitySince, activityStamp } from './agent-activity.mjs';
9
9
 
10
10
  // ── File watcher ───────────────────────────────────────────────────────────────
@@ -51,17 +51,9 @@ function watchBeads() {
51
51
  if (isSelfInflictedTouch(dir)) return;
52
52
  // Stale, not deleted. A change we NOTICED must not make the next reader pay
53
53
  // for it — they asked for something else. The broadcast below still reads
54
- // fresh, because a stale entry refreshes on read.
54
+ // fresh, because it explicitly awaits the shared asynchronous refresh.
55
55
  bdCacheStale(dir);
56
- for (const res of sseClients) {
57
- if (res._gctoCwd === dir) {
58
- try {
59
- res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(dir))}\n\n`);
60
- res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
61
- res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
62
- } catch {}
63
- }
64
- }
56
+ void broadcastTasks(dir, { fresh: true, projections: true });
65
57
  };
66
58
 
67
59
  // Debounce per-dir: dolt writes can fire 3-5 events in <50ms during a single
@@ -130,12 +122,9 @@ function watchVerdicts() {
130
122
  if (pushTimer) clearTimeout(pushTimer);
131
123
  // debounce: collapse a burst of writes (multiple agents finishing within ~150ms)
132
124
  pushTimer = setTimeout(() => {
133
- for (const res of sseClients) {
134
- const dir = res._gctoCwd || process.cwd();
135
- try {
136
- res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
137
- res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
138
- } catch { sseClients.delete(res); }
125
+ const dirs = new Set([...sseClients].map(res => res._gctoCwd || process.cwd()));
126
+ for (const dir of dirs) {
127
+ void broadcastTasks(dir, { projections: true });
139
128
  }
140
129
  }, 150);
141
130
  };
@@ -1768,8 +1768,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1768
1768
  .pl-gate.active .pl-msg, .pl-gate.active .pl-meta { opacity: 1; color: var(--text); }
1769
1769
  .pl-gate .gate-badge {
1770
1770
  margin-left: auto;
1771
- background: var(--status-blocked); color: var(--on-solid);
1772
- font-size: var(--fs-eyebrow); font-weight: 700;
1771
+ background: var(--status-blocked); color: var(--on-status);
1772
+ font-size: var(--fs-eyebrow); font-weight: 500;
1773
1773
  min-width: 16px; height: 16px; padding: 0 4px;
1774
1774
  border-radius: 0;
1775
1775
  display: inline-flex; align-items: center; justify-content: center;
@@ -8117,6 +8117,12 @@ function connectSSE() {
8117
8117
  };
8118
8118
  es.addEventListener('tasks', e => {
8119
8119
  const newTasks = JSON.parse(e.data);
8120
+ delete BOARD_DEGRADED['/api/tasks'];
8121
+ renderDegradedBanner('degraded-read', null, '');
8122
+ if (es.readyState === EventSource.OPEN) {
8123
+ document.getElementById('live-dot').className = 'live-dot pulse';
8124
+ document.getElementById('live-label').textContent = 'live · synced just now';
8125
+ }
8120
8126
  // A transient bd failure (dolt lock, timeout) can produce an empty
8121
8127
  // snapshot indistinguishable from "no tasks" (great_cto-e2ew). Don't
8122
8128
  // blindly wipe a populated board — refetch once via the regular API
@@ -8130,6 +8136,20 @@ function connectSSE() {
8130
8136
  }
8131
8137
  applyTasksSnapshot(newTasks);
8132
8138
  });
8139
+ es.addEventListener('task-read-state', e => {
8140
+ try {
8141
+ const state = JSON.parse(e.data);
8142
+ if (state.state !== 'unreadable') return;
8143
+ BOARD_DEGRADED['/api/tasks'] = 'Task snapshot is unavailable. Showing the last loaded tasks.';
8144
+ document.getElementById('live-dot').className = 'live-dot error';
8145
+ document.getElementById('live-label').textContent = 'connected · task data unavailable';
8146
+ // Re-evaluate existing healthy claims as well as adding the banner.
8147
+ // A previously painted "All clear" must not survive a failed refresh.
8148
+ if (window.__inboxData) renderInbox(window.__inboxData);
8149
+ renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
8150
+ 'Project data could not be refreshed. Counts may be out of date.');
8151
+ } catch { /* Malformed transport data must not erase the last snapshot. */ }
8152
+ });
8133
8153
  es.addEventListener('pipeline', e => {
8134
8154
  try {
8135
8155
  const stages = JSON.parse(e.data);
@@ -8165,8 +8185,10 @@ function connectSSE() {
8165
8185
  });
8166
8186
  es.onopen = () => {
8167
8187
  const d = document.getElementById('live-dot');
8168
- d.className = 'live-dot pulse';
8169
- document.getElementById('live-label').textContent = 'live · synced just now';
8188
+ const unreadable = degradedFor('/api/tasks');
8189
+ d.className = unreadable ? 'live-dot error' : 'live-dot pulse';
8190
+ document.getElementById('live-label').textContent = unreadable
8191
+ ? 'connected · task data unavailable' : 'live · synced just now';
8170
8192
  };
8171
8193
  es.onerror = () => {
8172
8194
  document.getElementById('live-dot').className = 'live-dot error';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.62.0",
3
+ "version": "3.62.1",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",